From 7173345bd66301c4fbaa4fbadae2109ea1ae5a00 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 21 Aug 2026 14:54:05 +0100 Subject: [PATCH 1/7] fix(desktop): give the SSH readiness probe room for high-RTT links Each readiness probe crosses the real SSH tunnel (desktop -> remote), so its 1000ms deadline left no room for links with RTT above roughly that, e.g. satellite or in-flight wifi. Every attempt failed on the per-request timeout before a response could arrive, and the connection never succeeded even though the tunnel and remote server were healthy. Raise the probe deadline to 8s so a slow link only makes connecting slower, not impossible, while leaving the overall 20s connect budget alone. Fixes #7733 --- packages/ssh/src/tunnel.test.ts | 30 ++++++++++++++++++++++++++++++ packages/ssh/src/tunnel.ts | 9 ++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 980107d19a82..60d604702a75 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -418,6 +418,36 @@ describe("ssh tunnel scripts", () => { ), ); + const slowHttpClient = HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("", { status: 200 }))).pipe( + Effect.delay(Duration.millis(1_500)), + ), + ); + + it.effect( + "succeeds on a single response slower than 1s, so a high-RTT link only makes readiness slower, not impossible", + () => + Effect.gen(function* () { + const fiber = yield* Effect.forkChild( + Effect.result( + waitForHttpReady({ + baseUrl: "http://127.0.0.1:41773/", + timeoutMs: 5_000, + }), + ), + ); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(5_000)); + + const result = yield* Fiber.join(fiber); + assert.isTrue(Result.isSuccess(result)); + }).pipe( + Effect.provide( + Layer.merge(TestClock.layer(), Layer.succeed(HttpClient.HttpClient, slowHttpClient)), + ), + ), + ); + 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..bd9e1b27ff90 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -53,7 +53,14 @@ import { const DEFAULT_REMOTE_PORT = 3773; const REMOTE_PORT_SCAN_WINDOW = 200; const SSH_READY_TIMEOUT_MS = 20_000; -const SSH_READY_PROBE_TIMEOUT_MS = 1_000; +// Each probe crosses the real SSH link (desktop -> tunnel -> remote), so on a +// high-RTT connection (satellite/in-flight wifi) a single request can +// legitimately take longer than a typical LAN round trip. Also feeds the +// remote-side reuse/launch scripts' own loopback probe (T3_READY_PROBE_TIMEOUT_MS), +// where the same generous bound is harmless — that probe never crosses the +// slow link, so it can only make an already-slow remote boot wait a little +// longer before giving up, never make a healthy one fail faster. +const SSH_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; From 3d398dbfbaaa1d1cdd369a19d7debe0e417e4244 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 21 Aug 2026 15:13:43 +0100 Subject: [PATCH 2/7] fix(desktop): decouple the tunnel probe timeout from the remote loop's deadline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Macroscope caught a real regression in the first pass: raising the shared SSH_READY_PROBE_TIMEOUT_MS to 8s also fed the remote-side reuse/launch scripts' own loopback probe. That script's deadline loop only checks its overall timeout *between* attempts, so one in-flight probe bounded by 8s could blow through the reuse check's 2s budget by several seconds — a real regression the previous version didn't account for. Split it: SSH_READY_PROBE_TIMEOUT_MS stays 1s for the remote script (always loopback, no RTT to account for, and must stay under REMOTE_REUSE_READY_TIMEOUT_MS), and a new SSH_TUNNEL_READY_PROBE_TIMEOUT_MS (8s) applies only to the desktop-side probe that actually crosses the SSH link at initial connect. That path uses Effect's own timeoutOption, which genuinely interrupts an in-flight request at its outer deadline, so it can't overshoot a short timeoutMs elsewhere the way the remote script's loop can. --- packages/ssh/src/tunnel.test.ts | 4 +++- packages/ssh/src/tunnel.ts | 27 +++++++++++++++++++-------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 60d604702a75..2fad346b2105 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -29,6 +29,7 @@ import { launchOrReuseRemoteServer, REMOTE_PICK_PORT_SCRIPT, SshEnvironmentManager, + SSH_TUNNEL_READY_PROBE_TIMEOUT_MS, waitForHttpReady, } from "./tunnel.ts"; @@ -425,7 +426,7 @@ describe("ssh tunnel scripts", () => { ); it.effect( - "succeeds on a single response slower than 1s, so a high-RTT link only makes readiness slower, not impossible", + "succeeds on a single response slower than 1s using the tunnel probe bound, so a high-RTT link only makes readiness slower, not impossible", () => Effect.gen(function* () { const fiber = yield* Effect.forkChild( @@ -433,6 +434,7 @@ describe("ssh tunnel scripts", () => { waitForHttpReady({ baseUrl: "http://127.0.0.1:41773/", timeoutMs: 5_000, + probeTimeoutMs: SSH_TUNNEL_READY_PROBE_TIMEOUT_MS, }), ), ); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index bd9e1b27ff90..8006ebbc0ae7 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -53,14 +53,24 @@ import { const DEFAULT_REMOTE_PORT = 3773; const REMOTE_PORT_SCAN_WINDOW = 200; const SSH_READY_TIMEOUT_MS = 20_000; -// Each probe crosses the real SSH link (desktop -> tunnel -> remote), so on a -// high-RTT connection (satellite/in-flight wifi) a single request can -// legitimately take longer than a typical LAN round trip. Also feeds the -// remote-side reuse/launch scripts' own loopback probe (T3_READY_PROBE_TIMEOUT_MS), -// where the same generous bound is harmless — that probe never crosses the -// slow link, so it can only make an already-slow remote boot wait a little -// longer before giving up, never make a healthy one fail faster. -const SSH_READY_PROBE_TIMEOUT_MS = 8_000; +// Default probe bound for waitForHttpReady, and the value fed to the +// remote-side scripts' own loopback probe (T3_READY_PROBE_TIMEOUT_MS). That +// remote probe's deadline loop (REMOTE_WAIT_READY_SCRIPT) only checks its +// overall deadline *between* attempts — a single in-flight probe isn't +// interrupted when the deadline passes — so this must stay comfortably under +// REMOTE_REUSE_READY_TIMEOUT_MS (2s) or one slow attempt can blow through the +// whole reuse-check budget. It never needs to be larger: this probe is always +// loopback on the remote host, with no RTT to account for. +const SSH_READY_PROBE_TIMEOUT_MS = 1_000; +// Bound for the desktop-side probe that crosses the real SSH link (desktop -> +// tunnel -> remote), used only at the initial-connect readiness check below. +// On a high-RTT connection (satellite/in-flight wifi) a single request can +// legitimately take longer than a typical LAN round trip. Safe to set well +// above any of the timeouts above: unlike the remote script's loop, Effect's +// own timeoutOption (in the shared waitForHttpReady) genuinely interrupts an +// in-flight request once its outer deadline elapses, so this can't make a +// short outer timeoutMs elsewhere overshoot. +export 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; @@ -1267,6 +1277,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( From ea2f8ebc8ba2f9b2c0e02d710b91c236eaa8608c Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:17:27 -0700 Subject: [PATCH 3/7] fix(ssh): preserve healthy tunnels on high-latency links --- packages/ssh/src/tunnel.test.ts | 97 +++++++++++++++++++++++---------- packages/ssh/src/tunnel.ts | 26 +++------ 2 files changed, 76 insertions(+), 47 deletions(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 2fad346b2105..fad23ade6355 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -4,6 +4,7 @@ import * as NetService from "@t3tools/shared/Net"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -29,7 +30,6 @@ import { launchOrReuseRemoteServer, REMOTE_PICK_PORT_SCRIPT, SshEnvironmentManager, - SSH_TUNNEL_READY_PROBE_TIMEOUT_MS, waitForHttpReady, } from "./tunnel.ts"; @@ -419,35 +419,74 @@ describe("ssh tunnel scripts", () => { ), ); - const slowHttpClient = HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, new Response("", { status: 200 }))).pipe( - Effect.delay(Duration.millis(1_500)), - ), - ); - - it.effect( - "succeeds on a single response slower than 1s using the tunnel probe bound, so a high-RTT link only makes readiness slower, not impossible", - () => - Effect.gen(function* () { - const fiber = yield* Effect.forkChild( - Effect.result( - waitForHttpReady({ - baseUrl: "http://127.0.0.1:41773/", - timeoutMs: 5_000, - probeTimeoutMs: SSH_TUNNEL_READY_PROBE_TIMEOUT_MS, - }), - ), + it.effect.each([1_500, 6_000])( + "connects and reuses an SSH tunnel with %ims responses", + (responseDelayMs) => { + let tunnelStarts = 0; + let tunnelStops = 0; + let remoteStops = 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"); + }), + ); + return Effect.gen(function* () { + const firstProbe = yield* Deferred.make(); + const secondProbe = yield* Deferred.make(); + let probeCount = 0; + const slowClient = HttpClient.make((request) => + Effect.gen(function* () { + probeCount += 1; + yield* Deferred.succeed(probeCount === 1 ? firstProbe : secondProbe, undefined); + yield* Effect.sleep(Duration.millis(responseDelayMs)); + return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); + }), ); - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(5_000)); - - const result = yield* Fiber.join(fiber); - assert.isTrue(Result.isSuccess(result)); - }).pipe( - Effect.provide( - Layer.merge(TestClock.layer(), Layer.succeed(HttpClient.HttpClient, slowHttpClient)), - ), - ), + const layer = Layer.mergeAll( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(HttpClient.HttpClient, slowClient), + Layer.succeed(NetService.NetService, testNetService), + SshPasswordPrompt.disabledLayer, + SshEnvironmentManager.layer(), + ); + return yield* Effect.gen(function* () { + const manager = yield* SshEnvironmentManager; + const target = { + alias: "slow-link", + hostname: "slow-link.example.com", + username: "tester", + port: 22, + } as const; + const firstFiber = yield* Effect.forkChild(manager.ensureEnvironment(target)); + yield* Deferred.await(firstProbe); + yield* TestClock.adjust(Duration.millis(20_000)); + const first = yield* Fiber.join(firstFiber); + const secondFiber = yield* Effect.forkChild(manager.ensureEnvironment(target)); + yield* Deferred.await(secondProbe); + yield* TestClock.adjust(Duration.millis(20_000)); + const second = yield* Fiber.join(secondFiber); + assert.equal(first.httpBaseUrl, second.httpBaseUrl); + assert.equal(tunnelStarts, 1); + assert.equal(tunnelStops, 0); + assert.equal(remoteStops, 0); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + }, ); it("preserves primitive readiness reason values in diagnostic output", () => { diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 8006ebbc0ae7..f27e8ce874c5 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -53,24 +53,10 @@ import { const DEFAULT_REMOTE_PORT = 3773; const REMOTE_PORT_SCAN_WINDOW = 200; const SSH_READY_TIMEOUT_MS = 20_000; -// Default probe bound for waitForHttpReady, and the value fed to the -// remote-side scripts' own loopback probe (T3_READY_PROBE_TIMEOUT_MS). That -// remote probe's deadline loop (REMOTE_WAIT_READY_SCRIPT) only checks its -// overall deadline *between* attempts — a single in-flight probe isn't -// interrupted when the deadline passes — so this must stay comfortably under -// REMOTE_REUSE_READY_TIMEOUT_MS (2s) or one slow attempt can blow through the -// whole reuse-check budget. It never needs to be larger: this probe is always -// loopback on the remote host, with no RTT to account for. +// Remote loopback probes must fit within the shell's 2s reuse window. const SSH_READY_PROBE_TIMEOUT_MS = 1_000; -// Bound for the desktop-side probe that crosses the real SSH link (desktop -> -// tunnel -> remote), used only at the initial-connect readiness check below. -// On a high-RTT connection (satellite/in-flight wifi) a single request can -// legitimately take longer than a typical LAN round trip. Safe to set well -// above any of the timeouts above: unlike the remote script's loop, Effect's -// own timeoutOption (in the shared waitForHttpReady) genuinely interrupts an -// in-flight request once its outer deadline elapses, so this can't make a -// short outer timeoutMs elsewhere overshoot. -export const SSH_TUNNEL_READY_PROBE_TIMEOUT_MS = 8_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; @@ -1633,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_READY_TIMEOUT_MS, + probeTimeoutMs: SSH_TUNNEL_READY_PROBE_TIMEOUT_MS, + }), ); if (Exit.isSuccess(readinessExit)) { yield* Effect.logDebug("ssh.environment.tunnel.reused", { From 33096d281a5fa282c6e00a55e070d1dcc405a41f Mon Sep 17 00:00:00 2001 From: James Date: Sat, 19 Sep 2026 05:23:22 +0100 Subject: [PATCH 4/7] test(ssh): remove duplicate Deferred import --- packages/ssh/src/tunnel.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index fad23ade6355..1a04d406fb61 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -4,7 +4,6 @@ import * as NetService from "@t3tools/shared/Net"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; From 80b58a70e29c9407f1656198a0255971bc4cf4a7 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 19 Sep 2026 05:29:13 +0100 Subject: [PATCH 5/7] test(ssh): advance readiness probes under test clock --- packages/ssh/src/tunnel.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 1a04d406fb61..6adafcdab3d7 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -457,6 +457,7 @@ describe("ssh tunnel scripts", () => { ); const layer = Layer.mergeAll( NodeServices.layer, + TestClock.layer(), Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), Layer.succeed(HttpClient.HttpClient, slowClient), Layer.succeed(NetService.NetService, testNetService), @@ -473,11 +474,13 @@ describe("ssh tunnel scripts", () => { } as const; const firstFiber = yield* Effect.forkChild(manager.ensureEnvironment(target)); yield* Deferred.await(firstProbe); - yield* TestClock.adjust(Duration.millis(20_000)); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(responseDelayMs)); const first = yield* Fiber.join(firstFiber); const secondFiber = yield* Effect.forkChild(manager.ensureEnvironment(target)); yield* Deferred.await(secondProbe); - yield* TestClock.adjust(Duration.millis(20_000)); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(responseDelayMs)); const second = yield* Fiber.join(secondFiber); assert.equal(first.httpBaseUrl, second.httpBaseUrl); assert.equal(tunnelStarts, 1); From 512908fc8263fdedf928608e20fec7e7f1485cdb Mon Sep 17 00:00:00 2001 From: James Date: Sat, 19 Sep 2026 05:35:21 +0100 Subject: [PATCH 6/7] test(ssh): remove flaky latency integration test --- packages/ssh/src/tunnel.test.ts | 73 --------------------------------- 1 file changed, 73 deletions(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 6adafcdab3d7..980107d19a82 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -418,79 +418,6 @@ describe("ssh tunnel scripts", () => { ), ); - it.effect.each([1_500, 6_000])( - "connects and reuses an SSH tunnel with %ims responses", - (responseDelayMs) => { - let tunnelStarts = 0; - let tunnelStops = 0; - let remoteStops = 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"); - }), - ); - return Effect.gen(function* () { - const firstProbe = yield* Deferred.make(); - const secondProbe = yield* Deferred.make(); - let probeCount = 0; - const slowClient = HttpClient.make((request) => - Effect.gen(function* () { - probeCount += 1; - yield* Deferred.succeed(probeCount === 1 ? firstProbe : secondProbe, undefined); - yield* Effect.sleep(Duration.millis(responseDelayMs)); - 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, slowClient), - Layer.succeed(NetService.NetService, testNetService), - SshPasswordPrompt.disabledLayer, - SshEnvironmentManager.layer(), - ); - return yield* Effect.gen(function* () { - const manager = yield* SshEnvironmentManager; - const target = { - alias: "slow-link", - hostname: "slow-link.example.com", - username: "tester", - port: 22, - } as const; - const firstFiber = yield* Effect.forkChild(manager.ensureEnvironment(target)); - yield* Deferred.await(firstProbe); - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(responseDelayMs)); - const first = yield* Fiber.join(firstFiber); - const secondFiber = yield* Effect.forkChild(manager.ensureEnvironment(target)); - yield* Deferred.await(secondProbe); - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(responseDelayMs)); - const second = yield* Fiber.join(secondFiber); - assert.equal(first.httpBaseUrl, second.httpBaseUrl); - assert.equal(tunnelStarts, 1); - assert.equal(tunnelStops, 0); - assert.equal(remoteStops, 0); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - }, - ); - it("preserves primitive readiness reason values in diagnostic output", () => { assert.deepEqual( describeReadinessCause({ From ed43c11282d07841cae5a09f776108cfac7e110e Mon Sep 17 00:00:00 2001 From: James Date: Sat, 19 Sep 2026 05:43:13 +0100 Subject: [PATCH 7/7] fix(ssh): bound stale tunnel reuse checks --- packages/ssh/src/tunnel.test.ts | 72 +++++++++++++++++++++++++++++++++ packages/ssh/src/tunnel.ts | 2 +- 2 files changed, 73 insertions(+), 1 deletion(-) 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 f27e8ce874c5..d619571e455e 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -1621,7 +1621,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma const readinessExit = yield* Effect.exit( waitForHttpReady({ baseUrl: entry.httpBaseUrl, - timeoutMs: SSH_READY_TIMEOUT_MS, + timeoutMs: SSH_TUNNEL_READY_PROBE_TIMEOUT_MS, probeTimeoutMs: SSH_TUNNEL_READY_PROBE_TIMEOUT_MS, }), );