From 3a2d1049c399b28da4ef0ad018854dc93a60295a Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 10 Aug 2026 21:45:23 -0700 Subject: [PATCH] [eric] resilience: a stalled request probes the backend instead of waiting out its timeout, so a wedged port surfaces in seconds --- frontend/src/shared/backendConnection.ts | 17 +++++++++++++++++ frontend/src/shared/config.ts | 14 +++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/frontend/src/shared/backendConnection.ts b/frontend/src/shared/backendConnection.ts index 61e88b89..e93237e1 100644 --- a/frontend/src/shared/backendConnection.ts +++ b/frontend/src/shared/backendConnection.ts @@ -62,5 +62,22 @@ export function noteBackendSuccess(): void { } } +// A request that STALLS (a squatted or wedged port accepts the connection and never answers) would +// otherwise take the full attempt timeout before counting as a failure, leaving the user staring at +// a spinner for ~25s+ with the signal still green. The stall watchdog asks the prober directly, so +// "is the backend actually alive" is answered in seconds regardless of how long the request hangs. +export function probeNow(): void { + if (!prober) return; + void prober().then(() => noteBackendSuccess()).catch(() => noteBackendFailure()); +} + +export function noteRequestStalled(): void { + if (!reachableNow) return; + probeNow(); + // Two probe failures are what flips the signal, matching the normal path; a backend that answers + // either probe is simply slow, not down, and nothing is shown. + setTimeout(() => { if (reachableNow) probeNow(); }, 1200); +} + // Harness/debug handle: lets a live session (CDP, support) read the signal without a store import. (window as unknown as { __OSW_CONN?: object }).__OSW_CONN = { backendReachable, onBackendReachability }; diff --git a/frontend/src/shared/config.ts b/frontend/src/shared/config.ts index 0982add1..c0aec765 100644 --- a/frontend/src/shared/config.ts +++ b/frontend/src/shared/config.ts @@ -1,4 +1,4 @@ -import { noteBackendFailure, noteBackendSuccess, setBackendProber } from '@/shared/backendConnection'; +import { noteBackendFailure, noteBackendSuccess, noteRequestStalled, setBackendProber } from '@/shared/backendConnection'; const _w = window as any; // Prefer the preload-injected port; if it's missing (preload raced the backend port being picked), re-query the live value before falling back to 8324. The bare 8324 guess is wrong on any machine where the backend landed on a fallback port (e.g. 8324 was held by a leftover backend); see the self-heal below. @@ -104,6 +104,14 @@ function _installAuthFetchInterceptor() { return { ...(finalInit ?? {}), signal: callerSignal ? AbortSignal.any([callerSignal, timeout]) : timeout }; }; + // Anything on loopback that hasn't answered in 8s is stalled, not slow; ask the prober rather than + // waiting out the attempt timeout, so a squatted/wedged port surfaces in seconds (ENG-242). + const STALL_MS = 8000; + const withStallWatch = async (run: () => Promise): Promise => { + const stall = setTimeout(() => noteRequestStalled(), STALL_MS); + try { return await run(); } finally { clearTimeout(stall); } + }; + window.fetch = async function patchedFetch(input: RequestInfo | URL, init?: RequestInit): Promise { try { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url; @@ -131,7 +139,7 @@ function _installAuthFetchInterceptor() { // legitimately slow); they still feed the reachability signal so the UI stays honest. if (method !== 'GET') { try { - const resp = await originalFetch(input, finalInit); + const resp = await withStallWatch(() => originalFetch(input, finalInit)); noteBackendSuccess(); return resp; } catch (err) { @@ -163,7 +171,7 @@ function _installAuthFetchInterceptor() { let lastErr: unknown = null; for (let attempt = 0; attempt <= GET_RETRY_DELAYS_MS.length; attempt++) { try { - const resp = await originalFetch(input, attemptInit(finalInit)); + const resp = await withStallWatch(() => originalFetch(input, attemptInit(finalInit))); if (isTransientStatus(resp.status) && attempt < GET_RETRY_DELAYS_MS.length) { await new Promise((r) => setTimeout(r, GET_RETRY_DELAYS_MS[attempt])); continue;