diff --git a/packages/ssh/src/runnerProcess.test.ts b/packages/ssh/src/runnerProcess.test.ts new file mode 100644 index 000000000..d89ee5582 --- /dev/null +++ b/packages/ssh/src/runnerProcess.test.ts @@ -0,0 +1,307 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as NodeNet from "node:net"; + +import { buildRemoteT3RunnerScript } from "./tunnel.ts"; + +const Started = Schema.Struct({ + pid: Schema.Number, + port: Schema.Number, + args: Schema.Array(Schema.String), +}); +const decodeStarted = Schema.decodeUnknownSync(Schema.fromJsonString(Started)); + +describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "remote runner process ownership", + () => { + it.live.each(["npx", "npm"] as const)( + "keeps the server PID and graceful shutdown through the %s fallback", + (packageManager) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-" }); + const bin = path.join(fixture, "bin"); + const cliPath = path.join(fixture, "installed cli.mjs"); + const callsPath = path.join(fixture, "package-manager-calls.jsonl"); + const packageSpec = "t3@0.0.35"; + yield* fs.makeDirectory(bin); + yield* fs.symlink(process.execPath, path.join(bin, "node")); + yield* fs.writeFileString( + cliPath, + `#!/usr/bin/env node +import * as net from "node:net"; +const server = net.createServer((socket) => { + socket.end(); + server.close(); +}); +process.on("SIGTERM", () => server.close(() => { + process.stdout.write("graceful shutdown\\n"); +})); +server.listen(Number(process.env.T3_TEST_PORT ?? 0), "127.0.0.1", () => { + process.stdout.write(JSON.stringify({ + pid: process.pid, + port: server.address().port, + args: process.argv.slice(2), + }) + "\\n"); +}); +`, + ); + yield* fs.chmod(cliPath, 0o700); + yield* fs.writeFileString( + path.join(bin, packageManager), + `#!/usr/bin/env node +const fs = require("node:fs"); +const childProcess = require("node:child_process"); +const args = process.argv.slice(2); +fs.appendFileSync(process.env.T3_TEST_CALLS, JSON.stringify(args) + "\\n"); +if (args.includes("--package")) { + process.stdout.write(process.env.T3_TEST_CLI + "\\n"); +} else { + const child = childProcess.spawn(process.execPath, [process.env.T3_TEST_CLI, ...args], { stdio: "inherit" }); + child.once("exit", (code) => { process.exitCode = code ?? 1; }); +} +`, + ); + yield* fs.chmod(path.join(bin, packageManager), 0o700); + + const runServer = (port = 0) => + Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make("/bin/sh", ["-s", "--", "serve", "a path with spaces"], { + cwd: fixture, + env: { + PATH: bin, + T3_TEST_CLI: cliPath, + T3_TEST_CALLS: callsPath, + T3_TEST_PORT: String(port), + }, + detached: false, + stdin: Stream.make( + new TextEncoder().encode(buildRemoteT3RunnerScript({ packageSpec })), + ), + }), + ); + const ready = yield* Deferred.make(); + const stdout: string[] = []; + const output = yield* child.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.runForEach((line) => + Effect.gen(function* () { + stdout.push(line); + if (stdout.length === 1) { + yield* Deferred.succeed(ready, decodeStarted(line)); + } + }), + ), + Effect.forkScoped, + ); + const stderr = yield* child.stderr.pipe( + Stream.decodeText(), + Stream.mkString, + Effect.forkScoped, + ); + const receipt = yield* Effect.raceFirst( + Deferred.await(ready), + Fiber.join(output).pipe( + Effect.flatMap(() => Fiber.join(stderr)), + Effect.flatMap((message) => + Effect.die(new Error(`Runner exited before listening: ${message}`)), + ), + ), + ); + // A failed PID assertion must still close the owned fixture server, including an npm child. + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + if (yield* child.isRunning) { + yield* Effect.callback((resume) => { + const connection = NodeNet.connect(receipt.port, "127.0.0.1"); + connection.on("error", () => undefined); + connection.once("close", () => resume(Effect.void)); + return Effect.sync(() => connection.destroy()); + }); + yield* child.exitCode; + } + }).pipe(Effect.orDie), + ); + assert.equal(receipt.pid, child.pid); + assert.deepEqual(receipt.args, ["serve", "a path with spaces"]); + yield* child.kill({ killSignal: "SIGTERM" }); + assert.equal(yield* child.exitCode, 0); + yield* Fiber.join(output); + assert.include(stdout, "graceful shutdown"); + return receipt.port; + }).pipe(Effect.scoped); + + const port = yield* runServer(); + assert.equal(yield* runServer(port), port); + const calls = (yield* fs.readFileString(callsPath)) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const expectedCall = [ + ...(packageManager === "npm" ? ["exec"] : []), + "--yes", + "--package", + packageSpec, + "--", + "sh", + "-c", + "command -v t3", + ]; + assert.deepEqual(calls, [expectedCall, expectedCall]); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + }, +); + +describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "remote runner install diagnostics", + () => { + const decodeArguments = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Array(Schema.String)), + ); + const cases = (["npx", "npm"] as const).flatMap((packageManager) => + ( + [ + "etarget", + "network", + "empty-success", + "success", + "failed-with-path", + "existing-cli", + "node-override", + ] as const + ).map((mode) => ({ packageManager, mode })), + ); + + it.live.each(cases)("handles $packageManager/$mode", ({ packageManager, mode }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-install-" }); + const bin = path.join(fixture, "bin"); + const cliPath = path.join(fixture, "installed cli.mjs"); + const callsPath = path.join(fixture, "installer-calls.jsonl"); + const packageSpec = "t3@0.0.39-nightly.20260905.1286"; + const args = ["serve", "a path with spaces"]; + yield* fs.makeDirectory(bin); + yield* fs.symlink(process.execPath, path.join(bin, "node")); + yield* fs.writeFileString( + cliPath, + `#!/usr/bin/env node +process.stdout.write(JSON.stringify(process.argv.slice(2)) + "\\n"); +`, + ); + yield* fs.chmod(cliPath, 0o700); + yield* fs.writeFileString(callsPath, ""); + yield* fs.writeFileString( + path.join(bin, packageManager), + `#!/usr/bin/env node +const fs = require("node:fs"); +fs.appendFileSync(process.env.T3_TEST_CALLS, JSON.stringify(process.argv.slice(2)) + "\\n"); +const mode = process.env.T3_TEST_MODE; +if (mode === "success" || mode === "failed-with-path") { + process.stdout.write(process.env.T3_TEST_CLI + "\\n"); +} +if (mode === "etarget" || mode === "failed-with-path") { + process.stderr.write("npm error code ETARGET\\nnpm error notarget No matching version found.\\n"); + process.exitCode = 42; +} else if (mode === "network") { + process.stderr.write("npm error code ENETUNREACH\\n"); + process.exitCode = 43; +} +`, + ); + yield* fs.chmod(path.join(bin, packageManager), 0o700); + if (mode === "existing-cli") yield* fs.symlink(cliPath, path.join(bin, "t3")); + + const child = yield* spawner.spawn( + ChildProcess.make("/bin/sh", ["-s", "--", ...args], { + cwd: fixture, + extendEnv: false, + env: { + PATH: bin, + T3_TEST_MODE: mode, + T3_TEST_CLI: cliPath, + T3_TEST_CALLS: callsPath, + }, + stdin: Stream.make( + new TextEncoder().encode( + buildRemoteT3RunnerScript({ + packageSpec, + ...(mode === "node-override" ? { nodeScriptPath: cliPath } : {}), + }), + ), + ), + }), + ); + const { stdout, stderr, exitCode } = yield* Effect.all( + { + stdout: child.stdout.pipe(Stream.decodeText(), Stream.mkString), + stderr: child.stderr.pipe(Stream.decodeText(), Stream.mkString), + exitCode: child.exitCode, + }, + { concurrency: "unbounded" }, + ); + const installFailed = + mode === "etarget" || mode === "network" || mode === "failed-with-path"; + const missingExecutable = mode === "empty-success"; + assert.equal(exitCode, installFailed || missingExecutable ? 1 : 0); + if (installFailed || missingExecutable) { + assert.equal(stdout, ""); + } else { + assert.deepEqual(decodeArguments(stdout), args); + } + if (installFailed) { + const npmError = mode === "network" ? "ENETUNREACH" : "ETARGET"; + assert.include(stderr, `npm error code ${npmError}\n`); + assert.include(stderr, `Remote host could not install ${packageSpec}.`); + assert.notInclude(stderr, "Remote host installed"); + assert.notInclude(stderr, "Install a C toolchain"); + } else if (missingExecutable) { + assert.include(stderr, `Remote host installed ${packageSpec}`); + assert.include(stderr, "npm produced no t3 executable"); + assert.include(stderr, "Install a C toolchain"); + } else { + assert.equal(stderr, ""); + } + const expectedCall = [ + ...(packageManager === "npm" ? ["exec"] : []), + "--yes", + "--package", + packageSpec, + "--", + "sh", + "-c", + "command -v t3", + ]; + const usesInstaller = mode !== "existing-cli" && mode !== "node-override"; + const calls = yield* fs.readFileString(callsPath); + if (usesInstaller) { + assert.deepEqual( + calls + .trim() + .split("\n") + .map((line) => decodeArguments(line)), + [expectedCall], + ); + } else { + assert.equal(calls, ""); + } + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + }, +); diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 1e88292b4..c3812259c 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -106,8 +106,7 @@ describe("ssh tunnel scripts", () => { assert.include(script, "T3_NODE_SCRIPT_PATH=''"); assert.include(script, 'exec t3 "$@"'); - assert.include(script, "exec npx --yes 't3@latest' \"$@\""); - assert.include(script, "exec npm exec --yes 't3@latest' -- \"$@\""); + assert.include(script, 'exec "$T3_CLI_PATH" "$@"'); assert.include(script, "could not install 't3@latest'"); assert.include(script, "require_installed_t3_cli npx --yes --package 't3@latest'"); assert.include(script, "require_installed_t3_cli npm exec --yes --package 't3@latest'"); @@ -142,8 +141,6 @@ describe("ssh tunnel scripts", () => { packageSpec: "t3@nightly; touch /tmp/t3-owned", }); - assert.include(script, "exec npx --yes 't3@nightly; touch /tmp/t3-owned' \"$@\""); - assert.include(script, "exec npm exec --yes 't3@nightly; touch /tmp/t3-owned' -- \"$@\""); assert.include( script, "require_installed_t3_cli npx --yes --package 't3@nightly; touch /tmp/t3-owned'", diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 213fd81e6..65d983a00 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -433,20 +433,24 @@ fi # never becomes ready. Resolve the CLI once up front so that install failure is # reported here, with npm's own output on stderr. require_installed_t3_cli() { - T3_CLI_PATH="$("$@" -- sh -c 'command -v t3' || true)" + if ! T3_CLI_PATH="$("$@" -- sh -c 'command -v t3')"; then + printf 'Remote host could not install %s. See npm output above for the cause.\\n' @@T3_PACKAGE_SPEC@@ >&2 + return 1 + fi if [ -n "$T3_CLI_PATH" ]; then return 0 fi printf 'Remote host installed %s but npm produced no t3 executable, which usually means a native dependency (node-pty) failed to build. Install a C toolchain on the remote host (Debian/Ubuntu: build-essential, Fedora/RHEL: gcc-c++ make, macOS: xcode-select --install) and try again.\\n' @@T3_PACKAGE_SPEC@@ >&2 return 1 } +# The launcher records this PID, so exec the CLI without an npm wrapper process. if command -v npx >/dev/null 2>&1; then require_installed_t3_cli npx --yes --package @@T3_PACKAGE_SPEC@@ || exit 1 - exec npx --yes @@T3_PACKAGE_SPEC@@ "$@" + exec "$T3_CLI_PATH" "$@" fi if command -v npm >/dev/null 2>&1; then require_installed_t3_cli npm exec --yes --package @@T3_PACKAGE_SPEC@@ || exit 1 - exec npm exec --yes @@T3_PACKAGE_SPEC@@ -- "$@" + exec "$T3_CLI_PATH" "$@" fi printf 'Remote host is missing the t3 CLI and could not install @@T3_PACKAGE_SPEC@@ because node/npm/npx are unavailable on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2 exit 1