diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index b10123ab6223..bba00e4bbcf5 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -135,6 +135,7 @@ Configure your version manager for non-interactive shells if this differs from your normal terminal. With nvm, setting a compatible default, such as `nvm alias default 24`, can resolve the problem. +Closing the desktop app leaves the remote server and running agents active. If SSH reconnecting fails after an app update, retry the launch once. Removing the connection stops a server that T3 Code launched; a server that was already running is left alone. diff --git a/packages/ssh/src/runnerProcess.test.ts b/packages/ssh/src/runnerProcess.test.ts index 82061c0d8c84..40a0a399d8fa 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, @@ -166,6 +170,97 @@ if (args.includes("--package")) { }, ); +describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "remote reconnect process ownership", + () => { + it.live.each(["managed", "external"] as const)( + "reuses the same %s server published in the runtime file", + (serverKind) => + 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-reconnect-" }); + const runner = { nodeScriptPath: path.join(fixture, "unused-cli.mjs") }; + const child = yield* spawner.spawn( + ChildProcess.make( + process.execPath, + [ + "--input-type=module", + "-e", + `import * as http from "node:http"; +import * as fs from "node:fs"; +const server = http.createServer((_request, response) => response.end("ready")); +process.on("SIGTERM", () => fs.writeFileSync("stopped", "SIGTERM")); +server.listen(0, "127.0.0.1", () => { + process.stdout.write(JSON.stringify({ pid: process.pid, port: server.address().port, args: [] }) + "\\n"); +}); +`, + ], + { cwd: fixture, detached: false }, + ), + ); + yield* Effect.addFinalizer(() => + child.kill({ killSignal: "SIGKILL" }).pipe(Effect.ignore), + ); + const started = decodeStarted( + yield* child.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.take(1), + Stream.mkString, + ), + ); + yield* fs.makeDirectory(path.join(fixture, "userdata")); + yield* fs.writeFileString( + path.join(fixture, "userdata", "server-runtime.json"), + `{"pid":${started.pid},"port":${started.port},"origin":"http://127.0.0.1:${started.port}"}`, + ); + yield* fs.writeFileString( + path.join(fixture, "run-t3.sh"), + `${buildRemoteT3RunnerScript(runner)}\n`, + ); + yield* fs.writeFileString(path.join(fixture, "port"), `${started.port}\n`); + yield* fs.writeFileString(path.join(fixture, "managed"), `${serverKind}\n`); + if (serverKind === "managed") { + yield* fs.writeFileString(path.join(fixture, "pid"), `${started.pid}\n`); + } + // Redirect state discovery and ownership files to this fixture only. + const script = buildRemoteLaunchScript(runner) + .replace(/^STATE_DIR=.*$/mu, 'STATE_DIR="$T3_TEST_STATE_DIR"') + .replace(/^DEFAULT_SERVER_HOME=.*$/mu, 'DEFAULT_SERVER_HOME="$T3_TEST_STATE_DIR"'); + const launch = yield* spawner.spawn( + ChildProcess.make("/bin/sh", ["-s", "--", "fixture"], { + cwd: fixture, + env: { T3_TEST_STATE_DIR: fixture }, + extendEnv: true, + stdin: Stream.make(new TextEncoder().encode(script)), + }), + ); + const result = yield* Effect.all( + { + stdout: launch.stdout.pipe(Stream.decodeText(), Stream.mkString), + stderr: launch.stderr.pipe(Stream.decodeText(), Stream.mkString), + exitCode: launch.exitCode, + }, + { concurrency: "unbounded" }, + ); + assert.equal(result.exitCode, 0, result.stderr); + assert.isFalse(yield* fs.exists(path.join(fixture, "stopped"))); + assert.isTrue(yield* child.isRunning); + assert.equal( + result.stdout, + `{"remotePort":${started.port},"serverKind":"${serverKind}"}\n`, + ); + assert.equal(yield* fs.readFileString(path.join(fixture, "managed")), `${serverKind}\n`); + if (serverKind === "managed") { + assert.equal(yield* fs.readFileString(path.join(fixture, "pid")), `${started.pid}\n`); + } + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + }, +); + describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( "remote stop process ownership", () => { diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index e2536ba92017..fb93b52672a5 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -478,7 +478,7 @@ describe("ssh tunnel scripts", () => { Effect.andThen( Effect.sync(() => { assert.equal(tunnelKillCount, 2); - assert.equal(stopCommandCount, mode === "failed stop" ? 3 : 2); + assert.equal(stopCommandCount, mode === "failed stop" ? 2 : 1); }), ), ); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 9cb6b25e4121..761a189accaf 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -527,7 +527,8 @@ 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 +# A managed server also publishes the default runtime file. Keep its ownership on reconnect. +if [ -n "$DEFAULT_REMOTE_PORT" ] && { [ "$REMOTE_MANAGED" != "managed" ] || [ "$REMOTE_PID" != "$DEFAULT_RUNTIME_PID" ]; }; then REMOTE_PORT="$DEFAULT_REMOTE_PORT" if wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then if [ "$REMOTE_MANAGED" = "managed" ]; then @@ -1379,55 +1380,19 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma ), ); tunnels.set(input.key, tunnelEntry); - const spawnerService = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystemService = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; yield* Scope.addFinalizer( entryScope, Effect.gen(function* () { - const stopRemote = tunnels.get(tunnelEntry.key) === tunnelEntry; - if (stopRemote) { + if (tunnels.get(tunnelEntry.key) === tunnelEntry) { tunnels.delete(tunnelEntry.key); } + // Client shutdown and tunnel replacement must leave remote work running. yield* tunnelEntry.process .kill({ killSignal: "SIGTERM", forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS, }) .pipe(Effect.ignore); - if (!stopRemote) { - return; - } - yield* Effect.logDebug("ssh.environment.tunnel.finalizer.start", { - ...sshTargetLogFields(tunnelEntry.target), - key: tunnelEntry.key, - localPort: tunnelEntry.localPort, - remotePort: tunnelEntry.remotePort, - }); - const authSecret = authSecrets.get(tunnelEntry.key) ?? null; - yield* stopRemoteServer( - tunnelEntry.target, - authSecret === null - ? { - batchMode: "yes", - interactiveAuth: false, - } - : { - authSecret, - batchMode: "no", - interactiveAuth: true, - }, - ).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawnerService), - Effect.provideService(FileSystem.FileSystem, fileSystemService), - Effect.provideService(Path.Path, pathService), - ); - yield* Effect.logDebug("ssh.environment.tunnel.finalizer.succeeded", { - ...sshTargetLogFields(tunnelEntry.target), - key: tunnelEntry.key, - localPort: tunnelEntry.localPort, - remotePort: tunnelEntry.remotePort, - }); }).pipe(Effect.ignore), ); yield* Effect.logDebug("ssh.environment.tunnel.create.succeeded", { @@ -1581,11 +1546,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma hasTunnel: entry !== null, }); if (entry !== null) { - // Explicit disconnect owns the remote stop so its failure reaches the caller. - yield* Effect.gen(function* () { - tunnels.delete(key); - yield* closeTunnelEntry(entry); - }).pipe(Effect.uninterruptible); + yield* closeTunnelEntry(entry); } yield* runWithSshAuth({ key,