From 3d707b0d6d9e503d7d7eeae8097619e3c6bf15f1 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 21 Aug 2026 14:54:05 +0100 Subject: [PATCH 1/2] 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 461509ea0ad2..d683e35be1b6 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -310,6 +310,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 12ab0027803c..ab80f7c5a586 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -52,7 +52,14 @@ import { export 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 8041aab43a4a5ea0d06770d6be9d8e0088e03609 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 21 Aug 2026 15:13:43 +0100 Subject: [PATCH 2/2] 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 d683e35be1b6..a585ec3fbdd2 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -23,6 +23,7 @@ import { launchOrReuseRemoteServer, REMOTE_PICK_PORT_SCRIPT, SshEnvironmentManager, + SSH_TUNNEL_READY_PROBE_TIMEOUT_MS, waitForHttpReady, } from "./tunnel.ts"; @@ -317,7 +318,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( @@ -325,6 +326,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 ab80f7c5a586..8959edcc2dbc 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -52,14 +52,24 @@ import { export 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; @@ -1107,6 +1117,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(