diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index e4b16b8f7190..ca090bc2870d 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -1,3 +1,4 @@ +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; @@ -94,6 +95,19 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { assert.deepEqual(commands, ["tar"]); assert.equal(yield* fs.readFileString(paths.sentinelPath), `${version}\n`); assert.isFalse(yield* fs.exists(path.join(paths.versionDir, "t3-runtime-archive"))); + if ((yield* HostProcessPlatform) !== "win32") { + // The old launcher must still be able to start this archive after the + // first npm-to-executable update, including from the final directory. + yield* fs.writeFileString(paths.entryPath, '#!/bin/sh\nprintf "%s\\n" "$@"\n'); + yield* fs.chmod(paths.entryPath, 0o755); + const runner = yield* ProcessRunner.make(); + const legacyStart = yield* runner.run({ + command: process.execPath, + args: [path.join(paths.versionDir, "node_modules/t3/dist/bin.mjs"), "serve"], + }); + assert.equal(Number(legacyStart.code), 0, legacyStart.stderr); + assert.equal(legacyStart.stdout.trim(), "serve"); + } }), ); @@ -209,6 +223,40 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { }), ); + it.effect("backfills a cached archive without downloading or replacing it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-legacy-cache-" }); + const cached = pinnedRuntimePaths(path, baseDir, version, "linux"); + const legacyEntry = path.join(cached.versionDir, "node_modules/t3/dist/bin.mjs"); + yield* fs.makeDirectory(cached.versionDir, { recursive: true }); + yield* fs.writeFileString(cached.entryPath, "cached executable\n"); + yield* fs.writeFileString(cached.sentinelPath, `${version}\n`); + const requests: string[] = []; + const commands: string[] = []; + yield* ensurePinnedRuntimeInstalled({ + baseDir, + version, + fs, + path, + platform: "linux", + arch: "x64", + httpClient: releaseHttpClient(yield* validChecksums, requests), + runner: extractingRunner(fs, path, commands), + validate: () => + fs.exists(legacyEntry).pipe( + Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing legacy entry"))), + Effect.orDie, + ), + }); + assert.deepEqual(requests, []); + assert.deepEqual(commands, []); + assert.equal(yield* fs.readFileString(cached.entryPath), "cached executable\n"); + assert.equal(yield* fs.readFileString(cached.sentinelPath), `${version}\n`); + }), + ); + it.effect("preserves a completed runtime when validation fails", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 680d80e46cd1..ef929ebb8236 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -8,6 +8,7 @@ import * as Option from "effect/Option"; import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { legacyCliLauncherScript } from "@t3tools/shared/legacyCliLauncher"; import { CLI_RELEASE_CHECKSUMS_FILE, cliArchiveFileName, @@ -229,6 +230,24 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( input: PinnedRuntimeInstallInput, ) { const { fs } = input; + // Old service launchers still use the npm entry point, including when an + // archive was cached before this compatibility wrapper existed. + const ensureLegacyEntry = Effect.fn("cloud.pinned_runtime.ensure_legacy_entry")( + function* (versionDir: string) { + const legacyDir = input.path.join(versionDir, "node_modules", "t3", "dist"); + const entryPath = input.path.join(legacyDir, "bin.mjs"); + if (yield* fs.exists(entryPath)) return; + yield* fs.makeDirectory(legacyDir, { recursive: true }); + yield* fs.writeFileString(entryPath, legacyCliLauncherScript("archive")); + }, + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ + step: "writing the legacy service entry point", + cause, + }), + ), + ); const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version, input.platform); const [versionDirExists, entryExists, sentinel] = yield* Effect.all([ fs.exists(paths.versionDir), @@ -242,6 +261,7 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( const alreadyPinned = entryExists && Option.isSome(sentinel) && sentinel.value.trim() === input.version; if (alreadyPinned) { + yield* ensureLegacyEntry(paths.versionDir); yield* input.validate(paths); return paths; } @@ -290,6 +310,8 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( return yield* Effect.gen(function* () { yield* installFromArchive(input, stagingDir); + yield* ensureLegacyEntry(stagingDir); + yield* input.validate(stagingPaths); yield* fs .writeFileString(stagingPaths.sentinelPath, `${input.version}\n`) @@ -328,7 +350,10 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( ), ), ); - if (!published) yield* input.validate(paths); + if (!published) { + yield* ensureLegacyEntry(paths.versionDir); + yield* input.validate(paths); + } return paths; }).pipe( Effect.ensuring(fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore)), diff --git a/packages/shared/package.json b/packages/shared/package.json index d30c26c2de79..b213261b0c3a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./legacyCliLauncher": { + "types": "./src/legacyCliLauncher.ts", + "import": "./src/legacyCliLauncher.ts" + }, "./delimitedPreview": { "types": "./src/delimitedPreview.ts", "import": "./src/delimitedPreview.ts" diff --git a/packages/shared/src/legacyCliLauncher.test.ts b/packages/shared/src/legacyCliLauncher.test.ts new file mode 100644 index 000000000000..0a0f3feabb76 --- /dev/null +++ b/packages/shared/src/legacyCliLauncher.test.ts @@ -0,0 +1,62 @@ +// @effect-diagnostics nodeBuiltinImport:off - Exercises real Node IPC and process signals. +import * as NodeChildProcess from "node:child_process"; +import * as NodeEvents from "node:events"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { expect, it } from "vite-plus/test"; + +import { legacyCliLauncherScript } from "./legacyCliLauncher.ts"; + +// oxlint-disable-next-line t3code/no-global-process-runtime -- This test launches a real host executable. +const hostPlatform = NodeOS.platform(); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Match the real executable used by the subprocess. +const hostArch = NodeOS.arch(); + +// The fixture executable uses a POSIX shebang. The wrapper itself also runs on Windows. +it.skipIf(hostPlatform === "win32").each(["npm", "archive"] as const)( + "keeps %s service IPC, arguments, and termination connected", + async (distribution) => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-legacy-launcher-")); + const entry = NodePath.join(root, "node_modules/t3/dist/bin.mjs"); + const executable = + distribution === "archive" + ? NodePath.join(root, "t3") + : NodePath.join(root, `node_modules/@t3code/t3-${hostPlatform}-${hostArch}/t3`); + await NodeFSP.mkdir(NodePath.dirname(entry), { recursive: true }); + await NodeFSP.mkdir(NodePath.dirname(executable), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(NodePath.dirname(executable), "package.json"), + '{"type":"commonjs"}', + ); + await NodeFSP.writeFile(entry, legacyCliLauncherScript(distribution)); + await NodeFSP.writeFile( + executable, + `#!${process.execPath} +process.on("SIGTERM", () => process.exit(23)); +process.on("message", message => process.send({ reply: message })); +process.send({ args: process.argv.slice(2) }); +`, + ); + await NodeFSP.chmod(executable, 0o755); + const child = NodeChildProcess.fork(entry, ["serve", "a path with spaces"], { silent: true }); + try { + expect((await NodeEvents.EventEmitter.once(child, "message"))[0]).toEqual({ + args: ["serve", "a path with spaces"], + }); + const reply = NodeEvents.EventEmitter.once(child, "message"); + child.send({ type: "trial-accepted" }); + expect((await reply)[0]).toEqual({ reply: { type: "trial-accepted" } }); + const exit = NodeEvents.EventEmitter.once(child, "exit"); + child.kill("SIGTERM"); + expect(await exit).toEqual([23, null]); + } finally { + if (child.exitCode === null && child.signalCode === null) { + const exit = NodeEvents.EventEmitter.once(child, "exit"); + child.kill("SIGTERM"); + await exit; + } + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }, +); diff --git a/packages/shared/src/legacyCliLauncher.ts b/packages/shared/src/legacyCliLauncher.ts new file mode 100644 index 000000000000..f159c41d3e08 --- /dev/null +++ b/packages/shared/src/legacyCliLauncher.ts @@ -0,0 +1,36 @@ +/** Node entry point for service launchers installed before executable releases. */ +export function legacyCliLauncherScript(distribution: "npm" | "archive"): string { + const executable = + distribution === "npm" + ? 'join(dirname(require.resolve("@t3code/t3-" + process.platform + "-" + process.arch + "/package.json")), executableName)' + : 'resolve(dirname(fileURLToPath(import.meta.url)), "../../..", executableName)'; + return `import { spawn } from "node:child_process"; +import { constants } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); +const executableName = process.platform === "win32" ? "t3.exe" : "t3"; +const executable = ${executable}; +const ipc = process.send !== undefined; +const child = spawn(executable, process.argv.slice(2), { + stdio: ipc ? ["inherit", "inherit", "inherit", "ipc"] : "inherit", +}); +const fail = (error) => { + if (!error) return; + process.stderr.write("t3: " + error.message + "\\n"); + child.kill("SIGTERM"); + process.exitCode = 1; +}; +if (ipc) { + process.on("message", (message) => { if (child.connected) child.send(message, fail); }); + child.on("message", (message) => { if (process.connected) process.send(message, fail); }); + process.on("disconnect", () => { if (child.connected) child.disconnect(); }); +} +for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.on(signal, () => child.kill(signal)); +} +child.on("error", (error) => { fail(error); process.exit(1); }); +child.on("exit", (code, signal) => process.exit(code ?? 128 + (constants.signals[signal] || 1))); +`; +} diff --git a/scripts/build-npm-platform-packages.test.ts b/scripts/build-npm-platform-packages.test.ts index 2e3a35a0e9c9..47568afbcb67 100644 --- a/scripts/build-npm-platform-packages.test.ts +++ b/scripts/build-npm-platform-packages.test.ts @@ -1,3 +1,4 @@ +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -151,7 +152,7 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { assert.equal(launcherManifest.name, "t3"); assert.equal(launcherManifest.version, VERSION); assert.deepStrictEqual(launcherManifest.bin, { t3: "./bin/t3.js" }); - assert.deepStrictEqual(launcherManifest.files, ["bin"]); + assert.deepStrictEqual(launcherManifest.files, ["bin", "dist"]); assert.deepStrictEqual(launcherManifest.optionalDependencies, { "@t3code/t3-darwin-arm64": VERSION, "@t3code/t3-linux-x64": VERSION, @@ -182,13 +183,50 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { // NODE_PATH stands in for node_modules: require.resolve finds the // platform package there exactly as it would after `npm install`. + const hostPlatform = yield* HostProcessPlatform; + const hostArch = yield* HostProcessArchitecture; const env = { ...process.env, NODE_PATH: fixture.outputDir } as Record; - const passthrough = yield* run(process.execPath, ["bin/t3.js", "serve", "--port", "1234"], { - cwd: launcherDir, - env, - }); - assert.equal(passthrough.stdout.trim(), "stub linux-x64 serve --port 1234"); - assert.equal(passthrough.exitCode, 7); + if (KEYS.some((key) => key === `${hostPlatform}-${hostArch}`)) { + const passthrough = yield* run(process.execPath, ["bin/t3.js", "serve", "--port", "1234"], { + cwd: launcherDir, + env, + }); + assert.equal( + passthrough.stdout.trim(), + `stub ${hostPlatform}-${hostArch} serve --port 1234`, + ); + assert.equal(passthrough.exitCode, 7); + + // Run the entry point used by already-installed service updaters from + // the published tarball, including their preflight arguments. + const installedLauncher = path.join(fixture.root, "installed-launcher"); + yield* fs.makeDirectory(installedLauncher); + const unpack = yield* run( + "tar", + ["-xf", path.join(fixture.outputDir, "t3.tgz"), "-C", installedLauncher], + { + cwd: fixture.root, + }, + ); + assert.equal(unpack.exitCode, 0, unpack.stderr); + const legacy = yield* run( + process.execPath, + [ + "dist/bin.mjs", + "__service-preflight", + "--database-path", + "a database.sqlite", + "--launcher-protocol", + "2", + ], + { cwd: path.join(installedLauncher, "package"), env }, + ); + assert.equal( + legacy.stdout.trim(), + `stub ${hostPlatform}-${hostArch} __service-preflight --database-path a database.sqlite --launcher-protocol 2`, + ); + assert.equal(legacy.exitCode, 7); + } const unsupported = yield* run(process.execPath, ["bin/t3.js", "--version"], { cwd: launcherDir, diff --git a/scripts/build-npm-platform-packages.ts b/scripts/build-npm-platform-packages.ts index f9417426b82b..f73610d10aa8 100644 --- a/scripts/build-npm-platform-packages.ts +++ b/scripts/build-npm-platform-packages.ts @@ -19,6 +19,7 @@ * bundleDependencies needs an arborist tree these flattened installs are * not), whereas `npm publish ` uploads the bytes as given. */ +import { legacyCliLauncherScript } from "@t3tools/shared/legacyCliLauncher"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -137,7 +138,7 @@ export function npmLauncherPackageManifest( license: serverPackageJson.license, repository: serverPackageJson.repository, bin: { t3: "./bin/t3.js" }, - files: ["bin"], + files: ["bin", "dist"], optionalDependencies: Object.fromEntries( platformKeys.map((key) => [npmPlatformPackageName(key), version]), ), @@ -342,6 +343,10 @@ const stageLauncherPackage = Effect.fn("stageLauncherPackage")(function* (input: const launcherScript = path.join(stageDir, "bin/t3.js"); yield* fs.writeFileString(launcherScript, NPM_LAUNCHER_SCRIPT); yield* fs.chmod(launcherScript, 0o755); + // Older service updaters and launchers run this exact path with Node. + // Keep it in the package so they can preflight and start the new executable. + yield* fs.makeDirectory(path.join(stageDir, "dist")); + yield* fs.writeFileString(path.join(stageDir, "dist/bin.mjs"), legacyCliLauncherScript("npm")); const readme = yield* path.fromFileUrl(new URL("../apps/server/README.md", import.meta.url)); if (yield* fs.exists(readme)) { yield* fs.copyFile(readme, path.join(stageDir, "README.md"));