diff --git a/scripts/build-npm-launcher.test.ts b/scripts/build-npm-launcher.test.ts index 4c7edd4763359..fd241e7543bae 100644 --- a/scripts/build-npm-launcher.test.ts +++ b/scripts/build-npm-launcher.test.ts @@ -47,7 +47,15 @@ const run = Effect.fn("test.run")(function* ( return { stdout, stderr, exitCode }; }, Effect.scoped); -const serveFakeRelease = Effect.fn("test.serveFakeRelease")(function* (root: string) { +const serveFakeRelease = Effect.fn("test.serveFakeRelease")(function* ( + root: string, + executable = `#!/bin/sh +if [ -n "$T3CODE_TEST_CALLS" ]; then echo "$*|$T3CODE_NPM_LAUNCHER_PATH" >> "$T3CODE_TEST_CALLS"; fi +if [ "$1" = "--version" ]; then echo "${VERSION}"; exit 0; fi +echo "stub $* launcher=$T3CODE_NPM_LAUNCHER_PATH" +exit 7 +`, +) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const key = `${yield* HostProcess.Platform}-${yield* HostProcess.Architecture}`; @@ -56,10 +64,7 @@ const serveFakeRelease = Effect.fn("test.serveFakeRelease")(function* (root: str yield* fs.makeDirectory(path.join(root, "release", stem, "node_modules/native"), { recursive: true, }); - yield* fs.writeFileString( - path.join(root, "release", stem, "t3"), - '#!/bin/sh\necho "stub $* launcher=$T3CODE_NPM_LAUNCHER_PATH"\nexit 7\n', - ); + yield* fs.writeFileString(path.join(root, "release", stem, "t3"), executable); yield* fs.chmod(path.join(root, "release", stem, "t3"), 0o755); const tar = yield* run("tar", ["-czf", archiveName, stem], { cwd: path.join(root, "release"), @@ -136,6 +141,7 @@ it.layer(NodeServices.layer)("build-npm-launcher", (it) => { const env = { PATH: process.env.PATH, T3CODE_HOME: home, + T3CODE_TEST_CALLS: path.join(root, "calls"), T3CODE_RELEASE_BASE_URL: release.baseUrl, }; const launcher = path.join(packageDir, "bin/t3.mjs"); @@ -145,6 +151,10 @@ it.layer(NodeServices.layer)("build-npm-launcher", (it) => { }); assert.equal(first.stdout.trim(), `stub serve a b launcher=${launcher}`, first.stderr); assert.equal(first.exitCode, 7); + assert.deepStrictEqual( + (yield* fs.readFileString(env.T3CODE_TEST_CALLS)).trim().split("\n"), + ["--version|", `serve a b|${launcher}`], + ); const versionDir = path.join(home, "runtime/versions", VERSION); assert.equal( (yield* fs.readFileString(path.join(versionDir, ".install-complete"))).trim(), @@ -158,8 +168,24 @@ it.layer(NodeServices.layer)("build-npm-launcher", (it) => { const requestsAfterFirst = release.requests.length; const second = yield* run(process.execPath, [launcher, "--version"], { cwd: root, env }); - assert.equal(second.exitCode, 7); + assert.equal(second.exitCode, 0); assert.equal(release.requests.length, requestsAfterFirst); + assert.deepStrictEqual( + (yield* fs.readFileString(env.T3CODE_TEST_CALLS)).trim().split("\n"), + ["--version|", `serve a b|${launcher}`, `--version|${launcher}`], + ); + + yield* fs.writeFileString(path.join(versionDir, "t3"), "#!/t3-test-missing-loader\n"); + for (const _attempt of [1, 2]) { + const cached = yield* run(process.execPath, [launcher, "serve"], { cwd: root, env }); + assert.equal(cached.exitCode, 1, cached.stderr); + assert.include(cached.stderr, "could not run"); + assert.equal(release.requests.length, requestsAfterFirst); + assert.equal( + yield* fs.readFileString(path.join(versionDir, ".install-complete")), + `${VERSION}\n`, + ); + } const ipcServer = path.join(home, "runtime/versions", VERSION, "t3"); yield* fs.writeFileString( @@ -189,8 +215,8 @@ it.layer(NodeServices.layer)("build-npm-launcher", (it) => { env: { ...env, T3CODE_HOME: raceHome }, }); const [one, two] = yield* Effect.all([launch, launch], { concurrency: "unbounded" }); - assert.equal(one.exitCode, 7, one.stderr); - assert.equal(two.exitCode, 7, two.stderr); + assert.equal(one.exitCode, 0, one.stderr); + assert.equal(two.exitCode, 0, two.stderr); assert.deepStrictEqual(yield* fs.readDirectory(path.join(raceHome, "runtime/versions")), [ VERSION, ]); @@ -296,12 +322,8 @@ fs.readFileSync = (file, ...rest) => { cwd: root, env: { ...env, T3CODE_HOME: staleOnlyHome }, }); - assert.equal( - replaced.stdout.trim(), - `stub --version launcher=${launcher}`, - replaced.stderr, - ); - assert.equal(replaced.exitCode, 7); + assert.equal(replaced.stdout.trim(), VERSION, replaced.stderr); + assert.equal(replaced.exitCode, 0); assert.deepStrictEqual( yield* fs.readDirectory(path.join(staleOnlyHome, "runtime/versions")), [VERSION], @@ -320,4 +342,65 @@ fs.readFileSync = (file, ...rest) => { ); }), ); + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "does not publish an unusable release", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const executable of [ + "#!/t3-test-missing-loader\n", + '#!/bin/sh\necho "runtime loader failed" >&2\nexit 42\n', + "#!/bin/sh\nkill -TERM $$\n", + '#!/bin/sh\ntrap \'echo caught > "$T3CODE_TEST_TERM"; exit 42\' TERM\necho ready > "$T3CODE_TEST_READY"\nwhile :; do :; done\n', + ]) { + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-npm-validation-test-" }); + const output = yield* buildNpmLauncher({ + version: VERSION, + outputDir: path.join(root, "out"), + }); + const release = yield* serveFakeRelease(root, executable); + const home = path.join(root, "home"); + const dir = path.join(home, "runtime/versions", VERSION); + const probeTimeoutHook = path.join(root, "probe-timeout.mjs"); + yield* fs.writeFileString( + probeTimeoutHook, + `import childProcess from "node:child_process"; +import { syncBuiltinESMExports } from "node:module"; +const { spawnSync } = childProcess; +childProcess.spawnSync = (command, args, options) => spawnSync(command, args, + options?.timeout === undefined ? options : { ...options, timeout: 500 }); +syncBuiltinESMExports(); +`, + ); + const env = { + PATH: process.env.PATH, + T3CODE_HOME: home, + T3CODE_RELEASE_BASE_URL: release.baseUrl, + T3CODE_TEST_READY: path.join(root, "probe-ready"), + T3CODE_TEST_TERM: path.join(root, "probe-term"), + }; + const launcher = path.join(output.packageDir, "bin/t3.mjs"); + const failed = yield* run( + process.execPath, + ["--import", probeTimeoutHook, launcher, "serve"], + { cwd: root, env }, + ); + assert.equal(failed.exitCode, 1, failed.stderr); + assert.deepStrictEqual(yield* fs.readDirectory(path.dirname(dir)), [], failed.stderr); + assert.include(failed.stderr, "could not run"); + if (executable.includes("runtime loader failed")) { + assert.include(failed.stderr, "runtime loader failed"); + } + if (executable.includes("trap")) { + assert.isTrue(yield* fs.exists(env.T3CODE_TEST_READY)); + assert.isFalse(yield* fs.exists(env.T3CODE_TEST_TERM)); + } + if ((yield* HostProcess.Platform) === "linux") { + assert.include(failed.stderr, "glibc"); + assert.include(failed.stderr, "musl"); + } + } + }), + ); }); diff --git a/scripts/npm-launcher/t3.mjs b/scripts/npm-launcher/t3.mjs index f8828210862e3..207814ba9d311 100644 --- a/scripts/npm-launcher/t3.mjs +++ b/scripts/npm-launcher/t3.mjs @@ -90,6 +90,24 @@ async function install(dir, key) { }); if (extract.status !== 0) throw new Error(`could not extract ${archive}`); fs.rmSync(file); + const validate = spawnSync( + path.join(staging, process.platform === "win32" ? "t3.exe" : "t3"), + ["--version"], + { + stdio: ["ignore", "ignore", "pipe"], + encoding: "utf8", + timeout: 30_000, + killSignal: "SIGKILL", + }, + ); + if (validate.status !== 0) { + throw executableError( + key, + validate.error?.message || + validate.stderr?.trim() || + `--version exited with ${validate.signal || validate.status}`, + ); + } fs.writeFileSync(path.join(staging, ".install-complete"), `${version}\n`); publish(staging, dir); } finally { @@ -172,12 +190,21 @@ async function main() { for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { process.on(signal, () => (ipc || signal !== "SIGINT") && child.kill(signal)); } - child.on("error", (error) => fail(error)); + child.on("error", (error) => fail(executableError(key, error.message))); child.on("exit", (code, signal) => process.exit(code ?? 128 + (os.constants.signals[signal] || 1)), ); } +function executableError(key, detail) { + return new Error( + `could not run T3 Code ${version} for ${key}: ${detail}` + + (process.platform === "linux" + ? "\nThe Linux release requires glibc; musl-based systems such as Alpine are not supported. Use a glibc-based Linux environment." + : ""), + ); +} + function fail(error) { process.stderr.write(`t3: ${error.message}\n`); process.exit(1);