From e61a017fcd21d72113829f8755b5b45b063977a6 Mon Sep 17 00:00:00 2001 From: F4llen Date: Thu, 24 Sep 2026 19:53:50 -0400 Subject: [PATCH 1/2] fix(ssh): keep the managed server when it is the one in server-runtime.json The SSH launcher reads the default home's server-runtime.json to adopt a server that took over, such as the background service. The launcher's own managed server runs against that same home, so it records itself there too. The launcher then treated it as a foreign server: it stopped the managed PID, marked the port external, found it dead, and started a new server. Every reconnect therefore restarted a healthy managed server and ended the provider sessions it owned. Hand off only when the runtime file names a different process. --- packages/ssh/src/runnerProcess.test.ts | 146 ++++++++++++++++++++++++- packages/ssh/src/tunnel.ts | 4 +- 2 files changed, 148 insertions(+), 2 deletions(-) diff --git a/packages/ssh/src/runnerProcess.test.ts b/packages/ssh/src/runnerProcess.test.ts index f26bbc39415e..ee4cc49b9991 100644 --- a/packages/ssh/src/runnerProcess.test.ts +++ b/packages/ssh/src/runnerProcess.test.ts @@ -11,7 +11,11 @@ import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as NodeNet from "node:net"; -import { buildRemoteStopScript, buildRemoteT3RunnerScript } from "./tunnel.ts"; +import { + buildRemoteLaunchScript, + buildRemoteStopScript, + buildRemoteT3RunnerScript, +} from "./tunnel.ts"; const Started = Schema.Struct({ pid: Schema.Number, @@ -251,3 +255,143 @@ server.listen(0, "127.0.0.1", () => { ); }, ); + +const LaunchResult = Schema.Struct({ + remotePort: Schema.Number, + serverKind: Schema.Literals(["external", "managed"]), +}); +const decodeLaunchResult = Schema.decodeUnknownSync(Schema.fromJsonString(LaunchResult)); + +// Stands in for `t3 serve`: answers the readiness probe and records itself in +// the home's server-runtime.json, as every real server does. +const FAKE_SERVER = `import * as fs from "node:fs"; +import * as http from "node:http"; +const flag = (name) => process.argv[process.argv.indexOf(name) + 1]; +const port = flag("--port"); +const home = flag("--base-dir"); +const runtimePath = home + "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/userdata/server-runtime.json"; +const server = http.createServer((_request, response) => response.end("ok")); +process.on("SIGTERM", () => { + fs.rmSync(runtimePath, { force: true }); + process.exit(0); +}); +server.listen(Number(port), "127.0.0.1", () => { + fs.mkdirSync(home + "/userdata", { recursive: true }); + fs.writeFileSync(runtimePath, JSON.stringify({ + version: 1, + pid: process.pid, + port: Number(port), + origin: "http://127.0.0.1:" + port, + })); + process.stdout.write("recorded\\n"); +}); +`; + +describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "remote launch server reuse", + () => { + it.live("keeps its own managed server and hands off to a different one", () => + 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-launch-" }); + const home = path.join(fixture, "home"); + const bin = path.join(fixture, "bin"); + const cliPath = path.join(fixture, "server.mjs"); + const stateDir = path.join(home, ".t3", "ssh-launch", "fixture"); + yield* fs.makeDirectory(bin); + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* fs.symlink(process.execPath, path.join(bin, "node")); + yield* fs.writeFileString(cliPath, FAKE_SERVER); + + const freePort = Effect.callback((resume) => { + const probe = NodeNet.createServer(); + probe.listen(0, "127.0.0.1", () => { + const { port } = probe.address() as NodeNet.AddressInfo; + probe.close(() => resume(Effect.succeed(port))); + }); + }); + // Seed the preferred port so the launch never scans the real 3773. + yield* fs.writeFileString(path.join(stateDir, "port"), `${yield* freePort}\n`); + + const serverPids: number[] = []; + // Launched servers outlive the script, so stop each captured PID. + yield* Effect.addFinalizer(() => + Effect.sync(() => { + for (const pid of serverPids) { + try { + process.kill(pid, "SIGKILL"); + } catch {} + } + }), + ); + const isAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }; + + const launch = Effect.fn("test.remoteLaunch")(function* () { + const child = yield* spawner.spawn( + ChildProcess.make("/bin/sh", ["-s", "--", "fixture"], { + cwd: fixture, + env: { HOME: home, PATH: `${bin}:/usr/bin:/bin` }, + stdin: Stream.make( + new TextEncoder().encode(buildRemoteLaunchScript({ nodeScriptPath: cliPath })), + ), + }), + ); + const result = yield* Effect.all( + { + stdout: child.stdout.pipe(Stream.decodeText(), Stream.mkString), + stderr: child.stderr.pipe(Stream.decodeText(), Stream.mkString), + exitCode: child.exitCode, + }, + { concurrency: "unbounded" }, + ); + assert.equal(result.exitCode, 0, result.stderr); + const pidText = yield* fs + .readFileString(path.join(stateDir, "pid")) + .pipe(Effect.orElseSucceed(() => "")); + const pid = Number.parseInt(pidText, 10); + if (Number.isInteger(pid)) serverPids.push(pid); + return { ...decodeLaunchResult(result.stdout.trim()), pid }; + }, Effect.scoped); + + const first = yield* launch(); + assert.equal(first.serverKind, "managed"); + const second = yield* launch(); + assert.deepEqual(second, first); + assert.isTrue(isAlive(first.pid)); + + // A different server in the default home is still adopted. + const externalPort = yield* freePort; + const external = yield* spawner.spawn( + ChildProcess.make( + process.execPath, + [cliPath, "--port", String(externalPort), "--base-dir", path.join(home, ".t3")], + { cwd: fixture, detached: false }, + ), + ); + yield* Effect.addFinalizer(() => + external.kill({ killSignal: "SIGKILL" }).pipe(Effect.ignore), + ); + // It has written server-runtime.json once it reports in. + yield* external.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.take(1), + Stream.runDrain, + ); + const handedOff = yield* launch(); + assert.equal(handedOff.serverKind, "external"); + assert.equal(handedOff.remotePort, externalPort); + assert.isFalse(isAlive(first.pid)); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + }, +); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 0473ffeceae2..0684c9e297ff 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -640,7 +640,9 @@ 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 shares the default home, so it records itself there too. +# Only a different process in that file is another server to hand off to. +if [ -n "$DEFAULT_REMOTE_PORT" ] && [ "$DEFAULT_RUNTIME_PID" != "$REMOTE_PID" ]; then REMOTE_PORT="$DEFAULT_REMOTE_PORT" if wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then if [ "$REMOTE_MANAGED" = "managed" ]; then From 5bfeafc67909c84f11959d61bfe5aa841b0a7ef0 Mon Sep 17 00:00:00 2001 From: F4llen Date: Thu, 24 Sep 2026 19:59:01 -0400 Subject: [PATCH 2/2] test(ssh): check the stopped server by its port, not its PID A reaped-late zombie still answers kill -0, so a PID check can report a stopped server as running on some Linux hosts. A refused connection is the behavior that matters. --- packages/ssh/src/runnerProcess.test.ts | 28 ++++++++++---------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/packages/ssh/src/runnerProcess.test.ts b/packages/ssh/src/runnerProcess.test.ts index ee4cc49b9991..c76d1ddb3f9c 100644 --- a/packages/ssh/src/runnerProcess.test.ts +++ b/packages/ssh/src/runnerProcess.test.ts @@ -318,22 +318,17 @@ describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( const serverPids: number[] = []; // Launched servers outlive the script, so stop each captured PID. yield* Effect.addFinalizer(() => - Effect.sync(() => { - for (const pid of serverPids) { - try { - process.kill(pid, "SIGKILL"); - } catch {} - } - }), + Effect.forEach(serverPids, (pid) => + Effect.try(() => process.kill(pid, "SIGKILL")).pipe(Effect.ignore), + ), ); - const isAlive = (pid: number) => { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } - }; + const accepts = (port: number) => + Effect.callback((resume) => { + const connection = NodeNet.connect(port, "127.0.0.1"); + connection.once("connect", () => resume(Effect.succeed(true))); + connection.once("error", () => resume(Effect.succeed(false))); + return Effect.sync(() => connection.destroy()); + }); const launch = Effect.fn("test.remoteLaunch")(function* () { const child = yield* spawner.spawn( @@ -366,7 +361,6 @@ describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( assert.equal(first.serverKind, "managed"); const second = yield* launch(); assert.deepEqual(second, first); - assert.isTrue(isAlive(first.pid)); // A different server in the default home is still adopted. const externalPort = yield* freePort; @@ -390,7 +384,7 @@ describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( const handedOff = yield* launch(); assert.equal(handedOff.serverKind, "external"); assert.equal(handedOff.remotePort, externalPort); - assert.isFalse(isAlive(first.pid)); + assert.isFalse(yield* accepts(first.remotePort)); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); },