From f978f29ed11ae42f7b327b9d1687e92ff2f169e9 Mon Sep 17 00:00:00 2001 From: ViperJuice Date: Mon, 26 Oct 2026 17:34:01 -0400 Subject: [PATCH 1/4] fix(ssh): preserve external server ownership during updates --- packages/ssh/src/tunnel.test.ts | 67 +++++++++++++++++++++++++++++++++ packages/ssh/src/tunnel.ts | 16 ++++---- 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 360009a6063e4..7bc9faed5c7fc 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -1,3 +1,8 @@ +// @effect-diagnostics nodeBuiltinImport:off - executes the generated POSIX launcher against an isolated filesystem fixture. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; @@ -82,6 +87,68 @@ 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"] as const)( + "handles SSH launch with %s external service state", + (scenario) => { + 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"), "external\n"); + 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" ? `printf '${process.pid} 3773'` : "exit 1"} ;; + '__ssh-helper wait-ready') + printf '%s\\n' "$4" >> "$HOME/probes" + ${scenario === "fresh" ? "exit 0" : scenario === "recovering" ? '[ "$4" -gt 2000 ]' : "exit 1"} ;; + '__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") { + 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); + assert.equal( + NodeFS.readFileSync(NodePath.join(state, "managed"), "utf8").trim(), + "external", + ); + if (scenario === "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"); + } + } finally { + NodeFS.rmSync(home, { recursive: true, force: true }); + } + }, + ); + 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 603803ea7fcf6..8d5ed87010cd3 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -666,9 +666,10 @@ 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)" + REMOTE_PORT="$DEFAULT_REMOTE_PORT" + REMOTE_MANAGED="external" + printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE" + printf 'external\\n' >"$MANAGED_FILE" fi fi if [ "$REMOTE_MANAGED" = "external" ]; then @@ -676,10 +677,9 @@ 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 - REMOTE_PID="" - REMOTE_PORT="" - REMOTE_MANAGED="" + elif [ -z "$REMOTE_PORT" ] || ! wait_ready "@@T3_READY_TIMEOUT_MS@@"; 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 elif [ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && server_running "$REMOTE_PID"; then # Reconnect owns the local tunnel, not the lifetime of an already running @@ -833,7 +833,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), From 72359d460cd601523a2597b7004bfc7746498871 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 06:23:58 +0000 Subject: [PATCH 2/4] fix(ssh): clean up managed server after external recovery --- packages/ssh/src/tunnel.test.ts | 112 +++++++++++++++++++------------- packages/ssh/src/tunnel.ts | 8 +-- 2 files changed, 70 insertions(+), 50 deletions(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 1e3adf7efca55..957ffbb9cb989 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -87,67 +87,89 @@ 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"] as const)( - "handles SSH launch with %s external service state", - (scenario) => { - 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"), "external\n"); - if (scenario !== "missing-port") - NodeFS.writeFileSync(NodePath.join(state, "port"), "3773\n"); - } - NodeFS.writeFileSync(NodePath.join(runtime, ".install-complete"), ARCHIVE.archiveVersion); + 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(runtime, "t3"), - `#!/bin/sh + 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" ? `printf '${process.pid} 3773'` : "exit 1"} ;; + ${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" ? '[ "$4" -gt 2000 ]' : "exit 1"} ;; + ${scenario === "fresh" ? "exit 0" : scenario === "recovering" || scenario === "managed-recovering" ? '[ "$4" -gt 2000 ]' : "exit 1"} ;; '__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") { - 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); + { 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") { + 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") { - 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"); - } - } finally { - NodeFS.rmSync(home, { recursive: true, force: true }); } - }, - ); + 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 }); + } + }); it("rejects archive versions that are not a single exact version segment", () => { for (const archiveVersion of [ diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index f23c7942d90b0..cff3acd2aab82 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -638,7 +638,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 @@ -658,10 +658,8 @@ elif [ -n "$DEFAULT_REMOTE_PORT" ]; then REMOTE_MANAGED="external" fi else - REMOTE_PORT="$DEFAULT_REMOTE_PORT" - REMOTE_MANAGED="external" - printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE" - printf 'external\\n' >"$MANAGED_FILE" + 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 From 3c8b218ae2d53e5bf936c89ee74a0b75fd585e65 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 2 Nov 2026 01:19:31 +0000 Subject: [PATCH 3/4] fix(ssh): recover from stale external server ownership --- packages/ssh/src/tunnel.test.ts | 4 ++-- packages/ssh/src/tunnel.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 957ffbb9cb989..7efb8b2087b18 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -125,7 +125,7 @@ case "$1 $2" in ${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 ]' : "exit 1"} ;; + ${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 ;; @@ -140,7 +140,7 @@ esac timeout: 10_000, }); assert.isUndefined(result.error); - if (scenario === "fresh") { + 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; diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index cff3acd2aab82..6941403ac6e3c 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -668,8 +668,13 @@ if [ "$REMOTE_MANAGED" = "external" ]; then if server_running "$ADOPTED_PID"; then : elif [ -z "$REMOTE_PORT" ] || ! wait_ready "@@T3_READY_TIMEOUT_MS@@"; 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 + 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="" fi elif [ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && server_running "$REMOTE_PID"; then # Reconnect owns the local tunnel, not the lifetime of an already running From 2c6440809c094096de912f0c415d9639d8de454f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 11:17:12 +0000 Subject: [PATCH 4/4] test(ssh): tolerate background launcher writes during fixture cleanup --- packages/ssh/src/tunnel.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 36063801c1f85..2b6d28f665350 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -173,7 +173,7 @@ esac } } finally { managed?.kill(); - NodeFS.rmSync(home, { recursive: true, force: true }); + NodeFS.rmSync(home, { recursive: true, force: true, maxRetries: 5 }); } });