From 87a914b8755e7f830f51ae76a54777c90964e990 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Tue, 11 Aug 2026 15:51:09 +0200 Subject: [PATCH] fix(ssh): let cold remote servers finish starting --- packages/ssh/src/tunnel.test.ts | 34 +++++++++++++++++++++++++++++++++ packages/ssh/src/tunnel.ts | 4 +++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 4c2ecb33183..4bc3aebe0a3 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -45,6 +45,16 @@ const makeSuccessfulProcess = (stdout: string) => { }); }; +const makeDelayedSuccessfulProcess = (stdout: string, delayMs: number) => { + const process = makeSuccessfulProcess(stdout); + return { + ...process, + exitCode: Effect.sleep(Duration.millis(delayMs)).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + }; +}; + const makeRunningProcess = (onKill: () => void) => { let finish: ((exitCode: ChildProcessSpawner.ExitCode) => void) | null = null; return ChildProcessSpawner.makeHandle({ @@ -173,6 +183,7 @@ describe("ssh tunnel scripts", () => { assert.include(buildRemoteLaunchScript(), '--base-dir "$DEFAULT_SERVER_HOME"'); assert.notInclude(buildRemoteLaunchScript(), "server-home"); assert.include(buildRemoteLaunchScript(), "Remote T3 server did not become ready"); + assert.include(buildRemoteLaunchScript(), 'wait_ready "60000"'); assert.include(buildRemoteLaunchScript({ packageSpec: "t3@nightly" }), "t3@nightly"); assert.include( buildRemotePairingScript(target), @@ -234,6 +245,29 @@ describe("ssh tunnel scripts", () => { }).pipe(Effect.provide(processLayer)); }); + it.effect("allows cold remote launches to exceed the default SSH command timeout", () => { + const target = { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + } as const; + const spawner = ChildProcessSpawner.make(() => + Effect.succeed(makeDelayedSuccessfulProcess('{"remotePort":3774}\n', 75_000)), + ); + const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); + const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer()); + + return Effect.gen(function* () { + const fiber = yield* Effect.forkChild(launchOrReuseRemoteServer(target)); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.seconds(75)); + + const result = yield* Fiber.join(fiber); + assert.equal(result.remotePort, 3774); + }).pipe(Effect.provide(processLayer)); + }); + it("allows the remote port picker to run without a state file path", () => { assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";'); }); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 179d1fcb547..a1611c5770f 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -54,7 +54,8 @@ const REMOTE_PORT_SCAN_WINDOW = 200; const SSH_READY_TIMEOUT_MS = 20_000; const SSH_READY_PROBE_TIMEOUT_MS = 1_000; const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; -const REMOTE_READY_TIMEOUT_MS = 15_000; +const REMOTE_READY_TIMEOUT_MS = 60_000; +const REMOTE_LAUNCH_TIMEOUT_MS = 90_000; const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; export interface RemoteT3RunnerOptions { @@ -705,6 +706,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo const result = yield* runSshCommand(target, { remoteCommandArgs: ["sh", "-s", "--", remoteStateKey(target)], stdin: buildRemoteLaunchScript(runner), + timeoutMs: REMOTE_LAUNCH_TIMEOUT_MS, ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),