authorgravatar for gbarros1095@gmail.comGuilherme Barros <gbarros1095@gmail.com> 2026-08-15 12:43:42+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:43:42+02:00
log672216d7e152241213a8757892f281e1f4434e8a
treedf2cc35225fb4c0b54a1496be721cf5c85b08904
parent75472802bc5ddaba860dc652000223600e529937
signature Signed by PGP key B5690EEEBB952194

fix(ssh): let cold remote servers finish starting (#6168)


2 files changed, 37 insertions(+), 1 deletions(-)

packages/ssh/src/tunnel.test.ts+34
...@@ -45,6 +45,16 @@ const makeSuccessfulProcess = (stdout: string) => {...@@ -45,6 +45,16 @@ const makeSuccessfulProcess = (stdout: string) => {
45 });45 });
46};46};
4747
48const makeDelayedSuccessfulProcess = (stdout: string, delayMs: number) => {
49 const process = makeSuccessfulProcess(stdout);
50 return {
51 ...process,
52 exitCode: Effect.sleep(Duration.millis(delayMs)).pipe(
53 Effect.as(ChildProcessSpawner.ExitCode(0)),
54 ),
55 };
56};
57
48const makeRunningProcess = (onKill: () => void) => {58const makeRunningProcess = (onKill: () => void) => {
49 let finish: ((exitCode: ChildProcessSpawner.ExitCode) => void) | null = null;59 let finish: ((exitCode: ChildProcessSpawner.ExitCode) => void) | null = null;
50 return ChildProcessSpawner.makeHandle({60 return ChildProcessSpawner.makeHandle({
...@@ -174,6 +184,7 @@ describe("ssh tunnel scripts", () => {...@@ -174,6 +184,7 @@ describe("ssh tunnel scripts", () => {
174 assert.include(buildRemoteLaunchScript(), '--base-dir "$DEFAULT_SERVER_HOME"');184 assert.include(buildRemoteLaunchScript(), '--base-dir "$DEFAULT_SERVER_HOME"');
175 assert.notInclude(buildRemoteLaunchScript(), "server-home");185 assert.notInclude(buildRemoteLaunchScript(), "server-home");
176 assert.include(buildRemoteLaunchScript(), "Remote T3 server did not become ready");186 assert.include(buildRemoteLaunchScript(), "Remote T3 server did not become ready");
187 assert.include(buildRemoteLaunchScript(), 'wait_ready "60000"');
177 assert.include(buildRemoteLaunchScript({ packageSpec: "t3@nightly" }), "t3@nightly");188 assert.include(buildRemoteLaunchScript({ packageSpec: "t3@nightly" }), "t3@nightly");
178 assert.include(189 assert.include(
179 buildRemotePairingScript(target),190 buildRemotePairingScript(target),
...@@ -235,6 +246,29 @@ describe("ssh tunnel scripts", () => {...@@ -235,6 +246,29 @@ describe("ssh tunnel scripts", () => {
235 }).pipe(Effect.provide(processLayer));246 }).pipe(Effect.provide(processLayer));
236 });247 });
237248
249 it.effect("allows cold remote launches to exceed the default SSH command timeout", () => {
250 const target = {
251 alias: "devbox",
252 hostname: "devbox.example.com",
253 username: "julius",
254 port: 2222,
255 } as const;
256 const spawner = ChildProcessSpawner.make(() =>
257 Effect.succeed(makeDelayedSuccessfulProcess('{"remotePort":3774}\n', 75_000)),
258 );
259 const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);
260 const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer());
261
262 return Effect.gen(function* () {
263 const fiber = yield* Effect.forkChild(launchOrReuseRemoteServer(target));
264 yield* Effect.yieldNow;
265 yield* TestClock.adjust(Duration.seconds(75));
266
267 const result = yield* Fiber.join(fiber);
268 assert.equal(result.remotePort, 3774);
269 }).pipe(Effect.provide(processLayer));
270 });
271
238 it("allows the remote port picker to run without a state file path", () => {272 it("allows the remote port picker to run without a state file path", () => {
239 assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";');273 assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";');
240 });274 });
packages/ssh/src/tunnel.ts+3-1
...@@ -54,7 +54,8 @@ const REMOTE_PORT_SCAN_WINDOW = 200;...@@ -54,7 +54,8 @@ const REMOTE_PORT_SCAN_WINDOW = 200;
54const SSH_READY_TIMEOUT_MS = 20_000;54const SSH_READY_TIMEOUT_MS = 20_000;
55const SSH_READY_PROBE_TIMEOUT_MS = 1_000;55const SSH_READY_PROBE_TIMEOUT_MS = 1_000;
56const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000;56const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000;
57const REMOTE_READY_TIMEOUT_MS = 15_000;57const REMOTE_READY_TIMEOUT_MS = 60_000;
58const REMOTE_LAUNCH_TIMEOUT_MS = 90_000;
58const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000;59const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000;
5960
60export interface RemoteT3RunnerOptions {61export interface RemoteT3RunnerOptions {
...@@ -705,6 +706,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo...@@ -705,6 +706,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo
705 const result = yield* runSshCommand(target, {706 const result = yield* runSshCommand(target, {
706 remoteCommandArgs: ["sh", "-s", "--", remoteStateKey(target)],707 remoteCommandArgs: ["sh", "-s", "--", remoteStateKey(target)],
707 stdin: buildRemoteLaunchScript(runner),708 stdin: buildRemoteLaunchScript(runner),
709 timeoutMs: REMOTE_LAUNCH_TIMEOUT_MS,
708 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),710 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),
709 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),711 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),
710 ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),712 ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),