diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 980107d19a82..0465afa3f5f2 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -418,6 +418,78 @@ describe("ssh tunnel scripts", () => { ), ); + it.effect("recreates a stale tunnel after one probe timeout", () => + Effect.gen(function* () { + let tunnelStarts = 0; + let tunnelStops = 0; + let remoteStops = 0; + let probeMode: "ready" | "refused" = "ready"; + const reuseProbeStarted = yield* Deferred.make(); + let probeCount = 0; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + const args = commandArgs(command); + if (args.includes("-N")) { + tunnelStarts += 1; + return makeRunningProcess(() => { + tunnelStops += 1; + }); + } + if (args.includes("sh") && args.includes("--")) { + return makeSuccessfulProcess('{"remotePort":3773,"serverKind":"managed"}\n'); + } + if (args.includes("sh")) { + remoteStops += 1; + return makeSuccessfulProcess('{"stopped":true}\n'); + } + return makeSuccessfulProcess("\n"); + }), + ); + const client = HttpClient.make((request) => + Effect.gen(function* () { + probeCount += 1; + if (probeMode === "refused") { + yield* Deferred.succeed(reuseProbeStarted, undefined); + return HttpClientResponse.fromWeb(request, new Response("", { status: 503 })); + } + return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); + }), + ); + const layer = Layer.mergeAll( + NodeServices.layer, + TestClock.layer(), + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(HttpClient.HttpClient, client), + Layer.succeed(NetService.NetService, testNetService), + SshPasswordPrompt.disabledLayer, + SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }), + ); + const target = { + alias: "stale-link", + hostname: "stale-link.example.com", + username: "tester", + port: 22, + } as const; + + return yield* Effect.gen(function* () { + const manager = yield* SshEnvironmentManager; + yield* manager.ensureEnvironment(target); + probeMode = "refused"; + const reconnect = yield* Effect.forkChild(manager.ensureEnvironment(target)); + yield* Deferred.await(reuseProbeStarted); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.seconds(8)); + probeMode = "ready"; + yield* Fiber.join(reconnect); + + assert.isAtLeast(probeCount, 3); + assert.equal(tunnelStarts, 2); + assert.equal(tunnelStops, 1); + assert.equal(remoteStops, 1); + }).pipe(Effect.provide(layer), Effect.scoped); + }), + ); + it("preserves primitive readiness reason values in diagnostic output", () => { assert.deepEqual( describeReadinessCause({ diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 135cb7decae9..d619571e455e 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -53,7 +53,10 @@ import { const DEFAULT_REMOTE_PORT = 3773; const REMOTE_PORT_SCAN_WINDOW = 200; const SSH_READY_TIMEOUT_MS = 20_000; +// Remote loopback probes must fit within the shell's 2s reuse window. const SSH_READY_PROBE_TIMEOUT_MS = 1_000; +// Probes through SSH cross the network, both when connecting and reusing a tunnel. +const SSH_TUNNEL_READY_PROBE_TIMEOUT_MS = 8_000; const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; const REMOTE_READY_TIMEOUT_MS = 60_000; const REMOTE_LAUNCH_TIMEOUT_MS = 90_000; @@ -1260,6 +1263,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: waitForHttpReady({ baseUrl: input.httpBaseUrl, timeoutMs: SSH_READY_TIMEOUT_MS, + probeTimeoutMs: SSH_TUNNEL_READY_PROBE_TIMEOUT_MS, }), exitFailure, ).pipe( @@ -1615,7 +1619,11 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma remotePort: entry.remotePort, }); const readinessExit = yield* Effect.exit( - waitForHttpReady({ baseUrl: entry.httpBaseUrl, timeoutMs: 2_000 }), + waitForHttpReady({ + baseUrl: entry.httpBaseUrl, + timeoutMs: SSH_TUNNEL_READY_PROBE_TIMEOUT_MS, + probeTimeoutMs: SSH_TUNNEL_READY_PROBE_TIMEOUT_MS, + }), ); if (Exit.isSuccess(readinessExit)) { yield* Effect.logDebug("ssh.environment.tunnel.reused", {