diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 65d6448deaf6a..2b6d28f665350 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -93,6 +93,90 @@ function commandArgs(command: ChildProcess.Command): ReadonlyArray { const ARCHIVE = { archiveVersion: "1.2.3-preview.20260911.4" } as const; describe("ssh tunnel scripts", () => { + it.each([ + "unavailable", + "recovering", + "discovered", + "missing-port", + "fresh", + "managed-recovering", + ] as const)("handles SSH launch with %s external service state", async (scenario) => { + const managed = + scenario === "managed-recovering" ? NodeChildProcess.spawn("sleep", ["60"]) : undefined; + const exited = managed + ? new Promise((resolve) => managed.once("exit", (_code, signal) => resolve(signal))) + : undefined; + const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-ssh-owner-")); + try { + const state = NodePath.join(home, ".t3/ssh-launch/test"); + const runtime = NodePath.join(home, ".t3/runtime/versions", ARCHIVE.archiveVersion); + NodeFS.mkdirSync(state, { recursive: true }); + NodeFS.mkdirSync(runtime, { recursive: true }); + if (scenario !== "discovered" && scenario !== "fresh") { + NodeFS.writeFileSync( + NodePath.join(state, "managed"), + scenario === "managed-recovering" ? "managed\n" : "external\n", + ); + if (managed) NodeFS.writeFileSync(NodePath.join(state, "pid"), String(managed.pid)); + if (scenario !== "missing-port") + NodeFS.writeFileSync(NodePath.join(state, "port"), "3773\n"); + } + NodeFS.writeFileSync(NodePath.join(runtime, ".install-complete"), ARCHIVE.archiveVersion); + NodeFS.writeFileSync( + NodePath.join(runtime, "t3"), + `#!/bin/sh +case "$1 $2" in + '--version ') exit 0 ;; + '__ssh-helper runtime-port') + ${scenario === "discovered" || scenario === "managed-recovering" ? `printf '${process.pid} 3773'` : "exit 1"} ;; + '__ssh-helper wait-ready') + printf '%s\\n' "$4" >> "$HOME/probes" + ${scenario === "fresh" ? "exit 0" : scenario === "recovering" || scenario === "managed-recovering" ? '[ "$4" -gt 2000 ]' : '[ "$3" = 3774 ]'} ;; + '__ssh-helper pick-port') printf 'pick-port\\n' >> "$HOME/spawns"; printf '3774' ;; + 'serve --host') printf 'serve\\n' >> "$HOME/spawns" ;; + *) exit 2 ;; +esac +`, + { mode: 0o700 }, + ); + const result = NodeChildProcess.spawnSync("sh", ["-s", "--", "test"], { + input: SshTunnel.buildRemoteLaunchScript(ARCHIVE), + env: { ...process.env, HOME: home }, + encoding: "utf8", + timeout: 10_000, + }); + assert.isUndefined(result.error); + if (scenario === "fresh" || scenario === "unavailable" || scenario === "missing-port") { + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { remotePort: 3774, serverKind: "managed" }); + return; + } + assert.isFalse(NodeFS.existsSync(NodePath.join(home, "spawns")), result.stderr); + if (scenario === "discovered") { + assert.isFalse(NodeFS.existsSync(NodePath.join(state, "managed"))); + } else { + assert.equal( + NodeFS.readFileSync(NodePath.join(state, "managed"), "utf8").trim(), + "external", + ); + } + if (scenario === "recovering" || scenario === "managed-recovering") { + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { remotePort: 3773, serverKind: "external" }); + } else { + assert.equal(result.status, 1); + assert.include(result.stderr, "external T3 server"); + } + if (managed) { + assert.isFalse(NodeFS.existsSync(NodePath.join(state, "pid"))); + assert.equal(await exited, "SIGTERM"); + } + } finally { + managed?.kill(); + NodeFS.rmSync(home, { recursive: true, force: true, maxRetries: 5 }); + } + }); + it("rejects archive versions that are not a single exact version segment", () => { for (const archiveVersion of [ "../other", diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 2c55e1a453aa7..81cd2c4dfeae8 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -640,7 +640,7 @@ if [ -n "$DEFAULT_REMOTE_PORT" ] && [ "$REMOTE_MANAGED" = "managed" ] && [ "$DEF printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE" elif [ -n "$DEFAULT_REMOTE_PORT" ]; then REMOTE_PORT="$DEFAULT_REMOTE_PORT" - if wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then + if wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@" || wait_ready "@@T3_READY_TIMEOUT_MS@@"; then ADOPTED_PID="$DEFAULT_RUNTIME_PID" if [ "$REMOTE_MANAGED" = "managed" ]; then if [ -n "$REMOTE_PID" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then @@ -660,9 +660,8 @@ elif [ -n "$DEFAULT_REMOTE_PORT" ]; then REMOTE_MANAGED="external" fi else - REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)" - REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)" - REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)" + printf 'The external T3 server did not become ready. Retry after its update or restart finishes; no replacement server was started.\\n' >&2 + exit 1 fi fi if [ "$REMOTE_MANAGED" = "external" ]; then @@ -670,7 +669,11 @@ if [ "$REMOTE_MANAGED" = "external" ]; then # it again could catch a momentary stall and launch a second server beside it. if server_running "$ADOPTED_PID"; then : - elif [ -z "$REMOTE_PORT" ] || ! wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then + elif [ -z "$REMOTE_PORT" ] || ! wait_ready "@@T3_READY_TIMEOUT_MS@@"; then + if [ -n "$DEFAULT_RUNTIME_PID" ] && server_running "$DEFAULT_RUNTIME_PID"; then + printf 'The external T3 server did not become ready. Retry after its update or restart finishes; no replacement server was started.\\n' >&2 + exit 1 + fi REMOTE_PID="" REMOTE_PORT="" REMOTE_MANAGED="" @@ -827,7 +830,7 @@ export function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string ); } -function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string { +export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string { return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, { T3_ARCHIVE_MODE: isNodeScriptRunner(input) ? "0" : "1", T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input),