diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index c6fb91c6a94723ef6e0df1c04265d7951a6c68c3..9bb303d6afef81125ea7cacbd4f6e4f232794da7 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -10,6 +10,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; @@ -250,7 +251,7 @@ describe("ssh tunnel scripts", () => { assert.include( launch, - '[ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null', + '[ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && server_running "$REMOTE_PID"', ); assert.include(launch, "RUNNER_CHANGED=1"); assert.include(launch, "ensure_remote_node_path()"); @@ -299,7 +300,10 @@ describe("ssh tunnel scripts", () => { assert.include(launch, "resolve_default_runtime_port()"); assert.include(launch, 'DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'); assert.include(launch, "if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port))"); - assert.include(launch, 'PID_TO_STOP="${REMOTE_PID:-$DEFAULT_RUNTIME_PID}"'); + assert.include( + launch, + 'if [ -n "$DEFAULT_REMOTE_PORT" ] && [ "$REMOTE_MANAGED" = "managed" ] && [ "$DEFAULT_RUNTIME_PID" = "$REMOTE_PID" ]; then', + ); assert.include(launch, 'REMOTE_PORT="$DEFAULT_REMOTE_PORT"'); assert.include(launch, 'rm -f "$PID_FILE"'); assert.include(launch, "printf 'external\\n' >\"$MANAGED_FILE\""); @@ -492,6 +496,59 @@ describe("ssh tunnel scripts", () => { }).pipe(Effect.provide(layerProcess)); }); + it.effect("replaces a stale SSH forward without stopping the remote server", () => + Effect.gen(function* () { + const probeStarted = yield* Deferred.make(); + let stale = false; + let forwards = 0; + let stops = 0; + let kills = 0; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + const args = commandArgs(command); + if (args.includes("-N")) { + forwards += 1; + stale = false; + return makeRunningProcess(() => { + kills += 1; + }); + } + if (args.includes("--")) return makeSuccessfulProcess('{"remotePort":3773}\n'); + if (args.includes("sh")) stops += 1; + return makeSuccessfulProcess(""); + }), + ); + const httpClient = HttpClient.make((request) => + stale + ? Deferred.succeed(probeStarted, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.succeed(HttpClientResponse.fromWeb(request, new Response("", { status: 200 }))), + ); + const layer = Layer.mergeAll( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(HttpClient.HttpClient, httpClient), + Layer.succeed(NetService.NetService, testNetService), + SshAuth.SshPasswordPrompt.disabledLayer, + SshTunnel.SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }), + ); + yield* Effect.gen(function* () { + const manager = yield* SshTunnel.SshEnvironmentManager; + const target = { alias: "devbox", hostname: "devbox", username: null, port: null }; + yield* manager.ensureEnvironment(target); + stale = true; + const reconnect = yield* Effect.forkChild(manager.ensureEnvironment(target)); + yield* Deferred.await(probeStarted); + yield* TestClock.adjust("2 seconds"); + yield* Fiber.join(reconnect); + assert.equal(forwards, 2); + assert.equal(kills, 1); + assert.equal(stops, 0); + yield* manager.disconnectEnvironment(target); + assert.equal(stops, 1); + }).pipe(Effect.provide(layer), Effect.scoped); + }), + ); + it.effect.each(["successful stop", "failed stop"] as const)( "closes the tunnel scope and starts fresh after a %s", (mode) => { @@ -767,6 +824,39 @@ describe("remote port picker", () => { ); }); +// Runs a generated script with an isolated HOME, as an SSH remote would. +const runScript = (home: string, args: ReadonlyArray) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make("sh", args, { + env: { PATH: process.env.PATH ?? "", HOME: home }, + extendEnv: false, + }), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + child.stdout.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ), + child.stderr.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + return { stdout, stderr, exitCode }; + }); + // The archive runner is generated shell; string assertions cannot prove the // lock excludes concurrent installers. Run the real script against a tiny // fake archive served from a file:// mirror. @@ -776,37 +866,7 @@ describe("archive runner script", () => { const windowsHost = hostPlatform === "win32"; const archiveVersion = "1.2.3-preview.20260911.4"; - const runRunner = (home: string, runner: string) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawner.spawn( - ChildProcess.make("sh", [runner, "--version"], { - env: { PATH: process.env.PATH ?? "", HOME: home }, - extendEnv: false, - }), - ); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - child.stdout.pipe( - Stream.decodeText(), - Stream.runFold( - () => "", - (acc, chunk) => acc + chunk, - ), - ), - child.stderr.pipe( - Stream.decodeText(), - Stream.runFold( - () => "", - (acc, chunk) => acc + chunk, - ), - ), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ); - return { stdout, stderr, exitCode }; - }); + const runRunner = (home: string, runner: string) => runScript(home, [runner, "--version"]); // A fake "executable" that answers --version, packed the way the release // workflow packs the real archive: one top-level directory named after the @@ -879,3 +939,365 @@ describe("archive runner script", () => { 60_000, ); }); + +// Reuse decisions also live in generated shell. Drive the real launch script +// against a fake server that answers readiness probes and writes the default +// runtime file the way `t3 serve` does. +describe("launch script", () => { + const windowsHost = HostProcess.Platform.defaultValue() === "win32"; + const FAKE_SERVER = `const fs = require("node:fs"); +const http = require("node:http"); +const path = require("node:path"); +const args = process.argv.slice(2); +const flag = (name) => args[args.indexOf(name) + 1]; +// --answer-once stalls every request after the first, like a busy server; +// --exit-after-answer exits right after the first answer. +const answerOnce = args.includes("--answer-once"); +const exitAfterAnswer = args.includes("--exit-after-answer"); +let answered = false; +const server = http.createServer((_request, response) => { + if (answerOnce && answered) return; + answered = true; + response.end("ok", () => { + if (exitAfterAnswer) process.exit(0); + }); +}); +server.listen(Number(flag("--port")), "127.0.0.1", () => { + const port = server.address().port; + const userdata = path.join(flag("--base-dir"), "userdata"); + fs.mkdirSync(userdata, { recursive: true }); + fs.writeFileSync( + path.join(userdata, "server-runtime.json"), + JSON.stringify({ version: 1, pid: process.pid, port, origin: "http://127.0.0.1:" + port }), + ); + console.log(port); +}); +`; + + const isAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }; + + const freeLoopbackPort = Effect.callback((resume) => { + const server = NodeNet.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close(() => + resume(Effect.succeed(typeof address === "object" && address ? address.port : 0)), + ); + }); + }); + + // Whether anything still accepts connections on the port. Unlike a PID + // check, this cannot mistake an unreaped, exited server for a running one. + const isListening = (port: number) => + Effect.callback((resume) => { + const socket = NodeNet.connect(port, "127.0.0.1"); + socket.once("connect", () => { + socket.destroy(); + resume(Effect.succeed(true)); + }); + socket.once("error", () => resume(Effect.succeed(false))); + }); + + const launchResult = (stdout: string) => JSON.parse(stdout.trim().split("\n").at(-1) ?? ""); + + // Writes the launch script for a fake server into a temporary HOME. The + // seeded remembered port keeps the test off the default 3773, which a live + // T3 on this machine may own. + const prepareLaunch = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-launch-script-" }); + const home = `${root}/home`; + const stateDir = `${home}/.t3/ssh-launch/test-state`; + const fakeServer = `${root}/fake-t3.cjs`; + const launch = `${root}/launch.sh`; + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* fs.writeFileString(`${stateDir}/port`, `${yield* freeLoopbackPort}\n`); + yield* fs.writeFileString(fakeServer, FAKE_SERVER); + yield* fs.writeFileString( + launch, + SshTunnel.buildRemoteLaunchScript({ nodeScriptPath: fakeServer }), + ); + const readManagedPid = fs + .readFileString(`${stateDir}/pid`) + .pipe(Effect.map((pid) => Number(pid.trim()))); + // Launched servers outlive the script, so stop each PID the script + // recorded, including one it recorded just before a failed assertion. + // SIGKILL also ends a server a test left stopped. + const launchedPids: Array = []; + yield* Effect.addFinalizer(() => + readManagedPid.pipe( + Effect.map((pid) => [...launchedPids, pid]), + Effect.orElseSucceed(() => launchedPids), + Effect.map((pids) => { + for (const pid of pids) { + if (isAlive(pid)) process.kill(pid, "SIGKILL"); + } + }), + ), + ); + const runLaunchScript = runScript(home, [launch, "test-state"]); + const runLaunch = runLaunchScript.pipe( + Effect.map((result) => { + assert.equal(result.exitCode, 0, result.stderr); + return launchResult(result.stdout); + }), + ); + const launchManaged = Effect.gen(function* () { + const launched = yield* runLaunch; + const pid = yield* readManagedPid; + launchedPids.push(pid); + assert.equal(launched.serverKind, "managed"); + return { launched, pid }; + }); + // Starts a separate server on the default home, as a service would, and + // returns the port it reports. An unreaped server runs under a parent that + // never waits on it, so once it exits it lingers as a zombie. + const startDefaultHomeServer = ( + flags: ReadonlyArray, + options?: { readonly unreaped?: boolean }, + ) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const args = [fakeServer, "serve", "--port", "0", "--base-dir", `${home}/.t3`, ...flags]; + const server = yield* spawner.spawn( + options?.unreaped + ? ChildProcess.make("sh", ["-c", 'node "$@" & exec sleep 600', "sh", ...args]) + : ChildProcess.make("node", args), + ); + const port = yield* server.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.runHead, + ); + return Number(Option.getOrThrow(port)); + }); + return { + home, + stateDir, + launch, + readManagedPid, + runLaunchScript, + runLaunch, + launchManaged, + startDefaultHomeServer, + }; + }); + + it.effect.skipIf(windowsHost)( + "reuses its own managed server and yields only to a different default-home server", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir, readManagedPid, runLaunch, launchManaged, startDefaultHomeServer } = + yield* prepareLaunch; + const { launched, pid: managedPid } = yield* launchManaged; + + // The managed server wrote the default runtime file itself, so a + // reconnect must keep it rather than stop it as an external server. + assert.deepEqual(yield* runLaunch, launched); + assert.equal(yield* readManagedPid, managedPid); + assert.isTrue(isAlive(managedPid)); + + // A remembered port that is stale must not hide our own server: the + // runtime file names its port. + yield* fs.writeFileString(`${stateDir}/port`, `${yield* freeLoopbackPort}\n`); + assert.deepEqual(yield* runLaunch, launched); + assert.equal(yield* readManagedPid, managedPid); + assert.equal( + (yield* fs.readFileString(`${stateDir}/port`)).trim(), + `${launched.remotePort}`, + ); + + // A different server on the default home, such as a service, still + // takes over from the managed one. + const servicePort = yield* startDefaultHomeServer([]); + assert.deepEqual(yield* runLaunch, { + remotePort: servicePort, + serverKind: "external", + }); + assert.isFalse(yield* isListening(launched.remotePort)); + assert.isFalse(yield* fs.exists(`${stateDir}/pid`)); + }).pipe(Effect.provide(NodeServices.layer)), + 60_000, + ); + + // A stopped process keeps its listener but answers nothing, like a server + // stuck in a long SQLite stall. + it.effect.skipIf(windowsHost)( + "waits for its busy managed server instead of replacing it", + () => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const { readManagedPid, runLaunch, launchManaged } = yield* prepareLaunch; + const { launched, pid } = yield* launchManaged; + + // It resumes after the two 2-second probes that used to replace it. + process.kill(pid, "SIGSTOP"); + yield* spawner.spawn(ChildProcess.make("sh", ["-c", `sleep 5; kill -CONT ${pid}`])); + assert.deepEqual(yield* runLaunch, launched); + assert.equal(yield* readManagedPid, pid); + assert.isTrue(isAlive(pid)); + }).pipe(Effect.provide(NodeServices.layer)), + 60_000, + ); + + it.effect.skipIf(windowsHost)( + "fails without stopping a managed server that never becomes ready", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir, launch, readManagedPid, runLaunchScript, launchManaged } = + yield* prepareLaunch; + const { launched, pid } = yield* launchManaged; + + // Shorten the one-minute readiness budget so the test stays fast. + const script = yield* fs.readFileString(launch); + assert.include(script, 'wait_ready "60000"'); + yield* fs.writeFileString( + launch, + script.replaceAll('wait_ready "60000"', 'wait_ready "1500"'), + ); + process.kill(pid, "SIGSTOP"); + const result = yield* runLaunchScript; + assert.equal(result.exitCode, 1, result.stdout); + assert.include(result.stderr, "the server was left running"); + assert.isTrue(isAlive(pid)); + assert.equal(yield* readManagedPid, pid); + assert.equal( + (yield* fs.readFileString(`${stateDir}/port`)).trim(), + `${launched.remotePort}`, + ); + assert.equal((yield* fs.readFileString(`${stateDir}/managed`)).trim(), "managed"); + }).pipe(Effect.provide(NodeServices.layer)), + 60_000, + ); + + it.effect.skipIf(windowsHost)( + "still replaces its managed server when the runner changes", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir, readManagedPid, runLaunch, launchManaged } = yield* prepareLaunch; + const { pid: previousPid } = yield* launchManaged; + + // A different runner on disk means the client updated: replace the server. + yield* fs.writeFileString(`${stateDir}/run-t3.sh`, "# previous runner\n"); + assert.equal((yield* runLaunch).serverKind, "managed"); + const replacementPid = yield* readManagedPid; + assert.notEqual(replacementPid, previousPid); + assert.isFalse(isAlive(previousPid)); + assert.isTrue(isAlive(replacementPid)); + }).pipe(Effect.provide(NodeServices.layer)), + 60_000, + ); + + it.effect.skipIf(windowsHost)( + "replaces a managed server that exited but was not reaped", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const { home, stateDir, readManagedPid, runLaunch, launchManaged } = yield* prepareLaunch; + const { launched, pid } = yield* launchManaged; + process.kill(pid, "SIGKILL"); + + // A crashed server whose parent never reaps it stays a zombie, which + // kill -0 still reports. Its pid file and runtime record keep naming it. + const parent = yield* spawner.spawn( + ChildProcess.make("sh", ["-c", "sleep 600 & echo $!; exec sleep 600"]), + ); + const zombie = Number( + Option.getOrThrow( + yield* parent.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.runHead), + ), + ); + process.kill(zombie, "SIGKILL"); + assert.include((yield* runScript(home, ["-c", `ps -o stat= -p ${zombie}`])).stdout, "Z"); + yield* fs.writeFileString(`${stateDir}/pid`, `${zombie}\n`); + yield* fs.writeFileString( + `${home}/.t3/userdata/server-runtime.json`, + JSON.stringify({ + version: 1, + pid: zombie, + port: launched.remotePort, + origin: `http://127.0.0.1:${launched.remotePort}`, + }), + ); + + assert.equal((yield* runLaunch).serverKind, "managed"); + const replacementPid = yield* readManagedPid; + assert.notEqual(replacementPid, zombie); + assert.isTrue(isAlive(replacementPid)); + }).pipe(Effect.provide(NodeServices.layer)), + 60_000, + ); + + it.effect.skipIf(windowsHost)( + "adopts a server it has no managed record for instead of restarting it", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir, launchManaged, runLaunch } = yield* prepareLaunch; + const { launched, pid } = yield* launchManaged; + + // An interrupted launch, or a recycled PID, can leave a PID that matches + // the runtime file without the managed marker. A changed runner must not + // turn that into a restart of a server this launcher cannot claim. + yield* fs.remove(`${stateDir}/managed`); + yield* fs.writeFileString(`${stateDir}/run-t3.sh`, "# superseded runner\n"); + assert.deepEqual(yield* runLaunch, { + remotePort: launched.remotePort, + serverKind: "external", + }); + assert.isTrue(isAlive(pid)); + }).pipe(Effect.provide(NodeServices.layer)), + 60_000, + ); + + it.effect.skipIf(windowsHost)( + "keeps an adopted default-home server without launching another beside it", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir, runLaunch, startDefaultHomeServer } = yield* prepareLaunch; + // Ownership files from an earlier managed launch that lost its PID. + yield* fs.writeFileString(`${stateDir}/managed`, "managed\n"); + const servicePort = yield* startDefaultHomeServer(["--answer-once"]); + + // The server answers adoption and then stalls. A second readiness + // probe would launch another server on the same home. + assert.deepEqual(yield* runLaunch, { remotePort: servicePort, serverKind: "external" }); + assert.isFalse(yield* fs.exists(`${stateDir}/pid`)); + }).pipe(Effect.provide(NodeServices.layer)), + 60_000, + ); + + it.effect.skipIf(windowsHost)( + "launches a replacement when the adopted server exits during the handover", + () => + Effect.gen(function* () { + const { readManagedPid, runLaunch, launchManaged, startDefaultHomeServer } = + yield* prepareLaunch; + const { pid: previousPid } = yield* launchManaged; + yield* startDefaultHomeServer(["--exit-after-answer"], { unreaped: true }); + + // The service answers adoption and then exits while the managed + // server is being stopped, staying a zombie, so the script must launch + // a new server rather than return the dead one. The freed port may be + // reused. + const result = yield* runLaunch; + assert.equal(result.serverKind, "managed"); + const replacementPid = yield* readManagedPid; + assert.notEqual(replacementPid, previousPid); + assert.isTrue(isAlive(replacementPid)); + }).pipe(Effect.provide(NodeServices.layer)), + 60_000, + ); +}); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index abce5b13efb16f5ba9ceb8d8f431e2016d1bcd0c..472dcfc4188d9fbf8289df4c8c72661f7e26bc4e 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -620,6 +620,14 @@ wait_for_pid_exit() { sleep 0.1 done } +# kill -0 also succeeds for a server that exited but was not reaped yet, so +# a zombie does not count as running. Without ps to tell, trust kill -0. +server_running() { + [ -n "$1" ] && kill -0 "$1" 2>/dev/null || return 1 + case "$(ps -o stat= -p "$1" 2>/dev/null || true)" in + *Z*) return 1 ;; + esac +} resolve_default_runtime_port() { if [ "$T3_ARCHIVE_MODE" = "1" ]; then "$RUNNER_FILE" __ssh-helper runtime-port "$DEFAULT_RUNTIME_FILE" @@ -652,18 +660,25 @@ REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)" DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port 2>/dev/null || true)" DEFAULT_RUNTIME_PID="" DEFAULT_REMOTE_PORT="" +ADOPTED_PID="" if [ -n "$DEFAULT_RUNTIME_INFO" ]; then DEFAULT_RUNTIME_PID="\${DEFAULT_RUNTIME_INFO%% *}" DEFAULT_REMOTE_PORT="\${DEFAULT_RUNTIME_INFO#* }" fi -if [ -n "$DEFAULT_REMOTE_PORT" ]; then +# The managed server runs on the default home, so it writes the default +# runtime file too. When that file names the PID this launcher recorded as +# managed, it describes our own server and its real port, not one to adopt. +if [ -n "$DEFAULT_REMOTE_PORT" ] && [ "$REMOTE_MANAGED" = "managed" ] && [ "$DEFAULT_RUNTIME_PID" = "$REMOTE_PID" ]; then + REMOTE_PORT="$DEFAULT_REMOTE_PORT" + 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 + ADOPTED_PID="$DEFAULT_RUNTIME_PID" if [ "$REMOTE_MANAGED" = "managed" ]; then - PID_TO_STOP="\${REMOTE_PID:-$DEFAULT_RUNTIME_PID}" - if [ -n "$PID_TO_STOP" ] && kill -0 "$PID_TO_STOP" 2>/dev/null; then - kill "$PID_TO_STOP" 2>/dev/null || true - wait_for_pid_exit "$PID_TO_STOP" + if [ -n "$REMOTE_PID" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then + kill "$REMOTE_PID" 2>/dev/null || true + wait_for_pid_exit "$REMOTE_PID" fi REMOTE_PID="" REMOTE_PORT="$DEFAULT_REMOTE_PORT" @@ -684,24 +699,27 @@ if [ -n "$DEFAULT_REMOTE_PORT" ]; then fi fi if [ "$REMOTE_MANAGED" = "external" ]; then - if [ -z "$REMOTE_PORT" ] || ! wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then + # A server adopted above has just answered. While it is still alive, probing + # 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="" fi -elif [ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then +elif [ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && server_running "$REMOTE_PID"; then + # Reconnect owns the local tunnel, not the lifetime of an already running + # server. A changed runner is the update path and still replaces it. if [ "$RUNNER_CHANGED" -eq 1 ]; then kill "$REMOTE_PID" 2>/dev/null || true wait_for_pid_exit "$REMOTE_PID" REMOTE_PID="" REMOTE_PORT="" REMOTE_MANAGED="" - elif ! wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then - kill "$REMOTE_PID" 2>/dev/null || true - wait_for_pid_exit "$REMOTE_PID" - REMOTE_PID="" - REMOTE_PORT="" - REMOTE_MANAGED="" + elif ! wait_ready "@@T3_READY_TIMEOUT_MS@@"; then + printf 'Remote T3 server is still running but did not become ready. Retry the connection; the server was left running.\\n' >&2 + exit 1 fi else REMOTE_PID="" @@ -1654,6 +1672,8 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma remotePort: entry.remotePort, cause: readinessExit.cause, }); + // Closing a stale forward must not run the manager-owned remote stop. + tunnels.delete(key); yield* closeTunnelEntry(entry); }