From 2cb37c84b931fd3ad64cd854986db24759475a1e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 16 Aug 2026 22:44:36 +0900 Subject: [PATCH 1/4] fix(gui): bound the 401 session re-bootstrap and make token resolution abort-aware The shared resolutionInFlight promise awaited reBootstrapSessionToken with no timeout and no caller signal: one stalled /opencodex-session wedged every /api/* fetch for the page lifetime (loopback sessions expire every 5 minutes, so it re-armed periodically). The bootstrap is now bounded (10s) and returns a tri-state minted/unavailable/failed; only a definitive refusal (4xx or meta-less OK) reaches the admin-token prompt, while transient failures (timeout, abort, network, 5xx) fail the wave and re-arm on the next 401. Callers race the shared resolution against their own abort signal without cancelling it for others, and the race listener is always removed. New tests: api-auth-deadline (4 cases). Evidence: devlog/_plan/260816_gui_loading_performance/001 (E2) --- gui/src/api.ts | 104 +++++++++++---- gui/tests/api-auth-deadline.test.ts | 192 ++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 28 deletions(-) create mode 100644 gui/tests/api-auth-deadline.test.ts diff --git a/gui/src/api.ts b/gui/src/api.ts index 1df043791b..bd020b2541 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -1,4 +1,5 @@ import { promptForAdminToken, type AdminTokenVerifier } from "./admin-token-dialog"; +import { createBoundedFetch } from "./bounded-fetch"; let installed = false; /** Shared 401 refresh gate — concurrent waiters join one prompt / token resolution. */ @@ -25,6 +26,13 @@ const SESSION_REBOOTSTRAP_PATH = "/opencodex-session"; /** Safe authenticated read used to validate a raw admin token before closing the sign-in form. */ const ADMIN_TOKEN_VALIDATION_PATH = "/api/settings"; +/** + * The silent re-bootstrap must fail fast: every /api/* request queues behind the + * shared resolution, so an unbounded bootstrap hangs the whole dashboard (H2). + */ +const SESSION_REBOOTSTRAP_TIMEOUT_MS = 10_000; +let rebootstrapTimeoutMs = SESSION_REBOOTSTRAP_TIMEOUT_MS; + function needsApiAuth(input: RequestInfo | URL): boolean { try { const raw = input instanceof Request ? input.url : String(input); @@ -102,23 +110,42 @@ function metaContentFromHtml(html: string, name: string): string | null { * Silently renew the GUI session from a freshly served document. Loopback servers mint * short-lived sessions into the HTML on every page load, so an expired session (5-minute * TTL) or one invalidated by a proxy restart is replaced without ever asking the user for - * a token. Returns null when the server refuses to mint sessions (non-loopback operator - * dashboards), where the manual admin-token prompt remains the fallback. + * a token. + * + * Tri-state by design: only a definitive refusal ("unavailable": 4xx, or an OK + * document without valid session meta — the non-loopback shape) may fall through to + * the admin-token prompt. Anything transient — timeout, abort, network error, 5xx + * from an intermediate proxy — is "failed", which settles this wave as an ordinary + * request failure and lets the next poll retry. Mapping a transient failure to the + * prompt would pop a credential modal on a loopback dashboard that needs no token. */ -async function reBootstrapSessionToken(): Promise { - if (!rawFetch) return null; +type RebootstrapResult = + | { kind: "minted"; token: string } + | { kind: "unavailable" } + | { kind: "failed" }; + +async function reBootstrapSessionToken(): Promise { + if (!rawFetch) return { kind: "failed" }; + const bounded = createBoundedFetch(rebootstrapTimeoutMs); try { - const response = await rawFetch(SESSION_REBOOTSTRAP_PATH, { cache: "no-store" }); - if (!response.ok) return null; + const response = await rawFetch(SESSION_REBOOTSTRAP_PATH, { cache: "no-store", signal: bounded.signal }); + if (!response.ok) { + // Only a definitive refusal is "unavailable"; 5xx and everything else is transient. + return response.status >= 400 && response.status < 500 ? { kind: "unavailable" } : { kind: "failed" }; + } const html = await response.text(); const stored = storeSession( metaContentFromHtml(html, "opencodex-session-token"), metaContentFromHtml(html, "opencodex-session-csrf"), metaContentFromHtml(html, "opencodex-session-origin"), ); - return stored ? readToken() : null; + const token = readToken(); + if (stored && token) return { kind: "minted", token }; + return { kind: "unavailable" }; } catch { - return null; + return { kind: "failed" }; + } finally { + bounded.clear(); } } @@ -162,30 +189,45 @@ function withToken(input: RequestInfo | URL, init: RequestInit | undefined, toke * memoryToken before prompting so waiters that wake after another request already stored a token * do not re-prompt. */ -async function resolveTokenAfter401(failedToken: string | null): Promise { +async function resolveTokenAfter401(failedToken: string | null, callerSignal?: AbortSignal): Promise { if (promptCancelled) return null; - if (resolutionInFlight) return resolutionInFlight; + if (callerSignal?.aborted) return null; + if (!resolutionInFlight) { + resolutionInFlight = (async () => { + if (promptCancelled) return null; + const current = readToken(); + if (current && current !== failedToken) return current; - resolutionInFlight = (async () => { - if (promptCancelled) return null; - const current = readToken(); - if (current && current !== failedToken) return current; + const renewed = await reBootstrapSessionToken(); + if (renewed.kind === "minted") return renewed.token; + // Transient bootstrap failure: this wave fails and the next 401 re-arms a + // fresh resolution (the finally clears resolutionInFlight). No prompt. + if (renewed.kind === "failed") return null; - const renewed = await reBootstrapSessionToken(); - if (renewed) return renewed; + const prompted = await requestAdminToken(verifyAdminToken); + if (prompted) { + storeToken(prompted); + return prompted; + } + promptCancelled = true; + return null; + })().finally(() => { + resolutionInFlight = null; + }); + } - const prompted = await requestAdminToken(verifyAdminToken); - if (prompted) { - storeToken(prompted); - return prompted; - } - promptCancelled = true; - return null; - })().finally(() => { - resolutionInFlight = null; + if (!callerSignal) return resolutionInFlight; + // Per-caller race: an abort unwinds THIS caller only — a dead caller must not + // cancel the shared resolution other waiters still need. The listener is removed + // whether the race resolves by token or by abort, so waiters never accumulate. + let onAbort: (() => void) | undefined; + const aborted = new Promise((resolve) => { + onAbort = () => resolve(null); + callerSignal.addEventListener("abort", onAbort, { once: true }); + }); + return Promise.race([resolutionInFlight, aborted]).finally(() => { + if (onAbort) callerSignal.removeEventListener("abort", onAbort); }); - - return resolutionInFlight; } export function installApiAuthFetch(): void { @@ -199,6 +241,7 @@ export function installApiAuthFetch(): void { window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { if (!needsApiAuth(input)) return originalFetch(input, init); + const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : undefined); const token = readToken(); const [firstInput, firstInit] = token ? withToken(input, init, token) : [input, init]; const response = await originalFetch(firstInput, firstInit); @@ -215,7 +258,7 @@ export function installApiAuthFetch(): void { clearTokenIfCurrent(token); } - const nextToken = await resolveTokenAfter401(token); + const nextToken = await resolveTokenAfter401(token, callerSignal ?? undefined); if (!nextToken) return response; const [retryInput, retryInit] = withToken(input, init, nextToken); @@ -236,3 +279,8 @@ export function resetApiAuthFetchForTests(adminTokenPrompt: AdminTokenPrompt = p promptCancelled = false; requestAdminToken = adminTokenPrompt; } + +/** Test-only: shrink the re-bootstrap deadline so timeout paths run in milliseconds. */ +export function setRebootstrapTimeoutForTests(ms: number): void { + rebootstrapTimeoutMs = ms; +} diff --git a/gui/tests/api-auth-deadline.test.ts b/gui/tests/api-auth-deadline.test.ts new file mode 100644 index 0000000000..44773c5720 --- /dev/null +++ b/gui/tests/api-auth-deadline.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { + installApiAuthFetch, + resetApiAuthFetchForTests, + setRebootstrapTimeoutForTests, +} from "../src/api"; + +const globals = ["document", "window", "navigator", "sessionStorage", "fetch"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let promptCalls: number; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map((key) => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + fetch: { configurable: true, value: testWindow.fetch.bind(testWindow) }, + }); + promptCalls = 0; + resetApiAuthFetchForTests(async () => { + promptCalls += 1; + return null; + }); +}); + +afterEach(() => { + resetApiAuthFetchForTests(); + setRebootstrapTimeoutForTests(10_000); + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function installMockAuthFetch(handler: typeof fetch): Promise { + Object.defineProperty(globalThis, "fetch", { configurable: true, value: handler }); + Object.defineProperty(window, "fetch", { configurable: true, value: handler }); + installApiAuthFetch(); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: window.fetch }); +} + +function sessionDocumentHtml(token: string, csrf: string, origin: string): string { + return [ + "", + ``, + ``, + ``, + "", + ].join(""); +} + +function pathnameOf(input: RequestInfo | URL): string { + return new URL(input instanceof Request ? input.url : String(input), "http://localhost/").pathname; +} + +/** A hang that honors the abort signal, like real fetch does. */ +function hangUntilAborted(signal?: AbortSignal | null): Promise { + return new Promise((_, reject) => { + signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); + }); +} + +const MINTED = () => new Response(sessionDocumentHtml("ocx_session_fresh", "fresh-csrf", "http://localhost"), { + status: 200, + headers: { "Content-Type": "text/html" }, +}); +test("hung bootstrap fails the wave within the deadline and a later wave re-bootstraps to success", async () => { + setRebootstrapTimeoutForTests(50); + let bootstrapCalls = 0; + let bootstrapHangs = true; + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const path = pathnameOf(input); + if (path === "/opencodex-session") { + bootstrapCalls += 1; + if (bootstrapHangs) return hangUntilAborted(init?.signal); + return MINTED(); + } + const key = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)).get("X-OpenCodex-API-Key"); + if (key === "ocx_session_fresh") return new Response("{}", { status: 200 }); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + // Wave 1: bootstrap hangs -> deadline -> wave settles with the original 401. + const first = await fetch("/api/config"); + expect(first.status).toBe(401); + expect(bootstrapCalls).toBe(1); + expect(promptCalls).toBe(0); + + // Wave 2: resolutionInFlight cleared — a fresh bootstrap runs and mints. + bootstrapHangs = false; + const second = await fetch("/api/config"); + expect(second.status).toBe(200); + expect(bootstrapCalls).toBe(2); + + // Wave 3: a valid session token means no further bootstrap at all. + const third = await fetch("/api/config"); + expect(third.status).toBe(200); + expect(bootstrapCalls).toBe(2); +}); + +test("bootstrap timeout and 5xx never open the admin-token prompt; only refusal does", async () => { + setRebootstrapTimeoutForTests(40); + let mode: "hang" | "bad-gateway" | "refuse" = "hang"; + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (pathnameOf(input) === "/opencodex-session") { + if (mode === "hang") return hangUntilAborted(init?.signal ?? (input instanceof Request ? input.signal : undefined)); + if (mode === "bad-gateway") return new Response("bad gateway", { status: 502 }); + return new Response("unauthorized", { status: 401 }); + } + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + expect((await fetch("/api/config")).status).toBe(401); // timeout path + mode = "bad-gateway"; + expect((await fetch("/api/config")).status).toBe(401); // 5xx path + expect(promptCalls).toBe(0); + + mode = "refuse"; // definitive 4xx -> prompt fallback + expect((await fetch("/api/config")).status).toBe(401); + expect(promptCalls).toBe(1); +}); + +test("caller abort during a pending resolution unwinds only that caller", async () => { + setRebootstrapTimeoutForTests(5_000); + let releaseBootstrap: (() => void) | null = null; + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (pathnameOf(input) === "/opencodex-session") { + return new Promise((resolve) => { + releaseBootstrap = () => resolve(MINTED()); + }); + } + const key = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)).get("X-OpenCodex-API-Key"); + if (key === "ocx_session_fresh") return new Response("{}", { status: 200 }); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + const controllerA = new AbortController(); + let adds = 0; + let removes = 0; + const origAdd = controllerA.signal.addEventListener.bind(controllerA.signal); + const origRemove = controllerA.signal.removeEventListener.bind(controllerA.signal); + controllerA.signal.addEventListener = ((...args: unknown[]) => { adds += 1; return (origAdd as (...a: unknown[]) => void)(...args); }) as typeof controllerA.signal.addEventListener; + controllerA.signal.removeEventListener = ((...args: unknown[]) => { removes += 1; return (origRemove as (...a: unknown[]) => void)(...args); }) as typeof controllerA.signal.removeEventListener; + + const a = fetch("/api/config", { signal: controllerA.signal }); + const b = fetch("/api/providers"); + // Both wait on the same pending bootstrap; abort A only. + await new Promise((resolve) => setTimeout(resolve, 30)); + controllerA.abort(); + const resA = await a; + expect(resA.status).toBe(401); + + let bSettled = false; + void b.then(() => { bSettled = true; }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(bSettled).toBe(false); + + releaseBootstrap!(); + const resB = await b; + expect(resB.status).toBe(200); + // The race listener on A's signal is removed whether the race wins or loses. + expect(adds).toBeGreaterThan(0); + expect(removes).toBe(adds); +}); + +test("the retried request carries the caller signal", async () => { + setRebootstrapTimeoutForTests(1_000); + const seenSignals: Array = []; + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (pathnameOf(input) === "/opencodex-session") return MINTED(); + seenSignals.push(init?.signal ?? (input instanceof Request ? input.signal : undefined)); + const key = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)).get("X-OpenCodex-API-Key"); + if (key === "ocx_session_fresh") return new Response("{}", { status: 200 }); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + const controller = new AbortController(); + const res = await fetch("/api/config", { signal: controller.signal }); + expect(res.status).toBe(200); + // First attempt + retry, both carrying the caller's signal. + expect(seenSignals.length).toBe(2); + expect(seenSignals[1]).toBe(controller.signal); +}); From 8638f0c9416c49ac22764b999e598c8636e9de5c Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 16 Aug 2026 23:15:30 +0900 Subject: [PATCH 2/4] fix(gui): whole-resolution watchdog for the 401 token path + harness-artifact correction Live verification found the residual: a bootstrap fetch that never honors the client abort (debugger-held or quirky network stack) leaves the shared resolution body pending forever even with the 10s fetch bound, re-wedging every /api waiter. A 15s whole-resolution watchdog now unwraps the wave as failed, and the conditional clear lets the next 401 start a fresh resolution (a late zombie settle cannot wipe it). Also corrects 001/E3: the post-heal non-recovery observation was a harness artifact (raw CDP rejects Fetch.disable); with interception properly cleared, all tabs recover automatically without reload. --- .../001_repro_evidence.md | 10 +++++ gui/src/api.ts | 39 +++++++++++++++++-- gui/tests/api-auth-deadline.test.ts | 34 ++++++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/devlog/_plan/260816_gui_loading_performance/001_repro_evidence.md b/devlog/_plan/260816_gui_loading_performance/001_repro_evidence.md index 822c315201..ccdf047a80 100644 --- a/devlog/_plan/260816_gui_loading_performance/001_repro_evidence.md +++ b/devlog/_plan/260816_gui_loading_performance/001_repro_evidence.md @@ -55,6 +55,16 @@ following 10s+, skeleton forever. Non-polled stores have no retry path at all on their single attempt is lost inside the auth wedge (no poll tick, no visibility listener without pollMs — client-resource.ts:175 installs it only while polling). +CORRECTION (2026-08-16, WP2 verification): the post-heal portion of this +observation was polluted by a test-harness artifact — the raw-CDP channel rejects +`Fetch.disable` (silently, via my own catch), so interception never actually +cleared and "heal" phases kept pausing requests. The mechanism claim stands on the +code (a non-polled cold store whose single attempt dies has no refire path), and +WP1's deadline converts it into a settled failed-cold with a working retry; but +the specific "never recovered even after heal" live observation is withdrawn as +evidence. The corrected end-to-end result (WP2 D addendum): after a real heal, +ALL tabs recover automatically with no reload. + ## E4 — hidden-tab emulation limit The in-app browser keeps background tabs `visibilityState: "visible"` (verified by diff --git a/gui/src/api.ts b/gui/src/api.ts index bd020b2541..10aa7e546d 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -33,6 +33,16 @@ const ADMIN_TOKEN_VALIDATION_PATH = "/api/settings"; const SESSION_REBOOTSTRAP_TIMEOUT_MS = 10_000; let rebootstrapTimeoutMs = SESSION_REBOOTSTRAP_TIMEOUT_MS; +/** + * Whole-resolution watchdog. The bootstrap bound covers a well-behaved fetch; this + * covers everything else — a fetch that never honors the abort, a prompt path that + * pends without settling, any surprise inside the shared body. Without it one stuck + * resolution pins every /api/* waiter for the page lifetime, which is the exact + * failure this module exists to kill. + */ +const RESOLUTION_WATCHDOG_MS = 15_000; +let resolutionWatchdogMs = RESOLUTION_WATCHDOG_MS; + function needsApiAuth(input: RequestInfo | URL): boolean { try { const raw = input instanceof Request ? input.url : String(input); @@ -193,7 +203,7 @@ async function resolveTokenAfter401(failedToken: string | null, callerSignal?: A if (promptCancelled) return null; if (callerSignal?.aborted) return null; if (!resolutionInFlight) { - resolutionInFlight = (async () => { + const body = (async () => { if (promptCancelled) return null; const current = readToken(); if (current && current !== failedToken) return current; @@ -211,9 +221,25 @@ async function resolveTokenAfter401(failedToken: string | null, callerSignal?: A } promptCancelled = true; return null; - })().finally(() => { - resolutionInFlight = null; + })(); + // The watchdog loses the race to a healthy resolution by seconds; when it wins, + // waiters unwrap as a failed wave and the conditional clear lets the NEXT 401 + // start a fresh resolution instead of joining the zombie. + let watchdog: ReturnType | undefined; + const watched = Promise.race([ + body, + new Promise((resolve) => { + watchdog = setTimeout(() => resolve(null), resolutionWatchdogMs); + }), + ]); + const tracked = watched.finally(() => { + clearTimeout(watchdog); + // Only clear if nobody replaced us — a zombie body settle late must not + // wipe a newer in-flight resolution. (Async callback: tracked is assigned + // long before this can run.) + if (resolutionInFlight === tracked) resolutionInFlight = null; }); + resolutionInFlight = tracked; } if (!callerSignal) return resolutionInFlight; @@ -278,9 +304,16 @@ export function resetApiAuthFetchForTests(adminTokenPrompt: AdminTokenPrompt = p rawFetch = null; promptCancelled = false; requestAdminToken = adminTokenPrompt; + rebootstrapTimeoutMs = SESSION_REBOOTSTRAP_TIMEOUT_MS; + resolutionWatchdogMs = RESOLUTION_WATCHDOG_MS; } /** Test-only: shrink the re-bootstrap deadline so timeout paths run in milliseconds. */ export function setRebootstrapTimeoutForTests(ms: number): void { rebootstrapTimeoutMs = ms; } + +/** Test-only: shrink the whole-resolution watchdog so zombie paths run in milliseconds. */ +export function setResolutionWatchdogForTests(ms: number): void { + resolutionWatchdogMs = ms; +} diff --git a/gui/tests/api-auth-deadline.test.ts b/gui/tests/api-auth-deadline.test.ts index 44773c5720..754f3b792d 100644 --- a/gui/tests/api-auth-deadline.test.ts +++ b/gui/tests/api-auth-deadline.test.ts @@ -4,6 +4,7 @@ import { installApiAuthFetch, resetApiAuthFetchForTests, setRebootstrapTimeoutForTests, + setResolutionWatchdogForTests, } from "../src/api"; const globals = ["document", "window", "navigator", "sessionStorage", "fetch"] as const; @@ -31,6 +32,7 @@ beforeEach(() => { afterEach(() => { resetApiAuthFetchForTests(); setRebootstrapTimeoutForTests(10_000); + setResolutionWatchdogForTests(15_000); testWindow.close(); for (const key of globals) { Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); @@ -190,3 +192,35 @@ test("the retried request carries the caller signal", async () => { expect(seenSignals.length).toBe(2); expect(seenSignals[1]).toBe(controller.signal); }); +test("a signal-dropping hung bootstrap is bounded by the whole-resolution watchdog", async () => { + // The bootstrap bound alone relies on fetch honoring the abort; a fetch that + // ignores it would otherwise pin the shared resolution (and every /api waiter) + // for the page lifetime. The watchdog must unwrap the wave and let the next one + // start a FRESH resolution. + setRebootstrapTimeoutForTests(50); + setResolutionWatchdogForTests(300); + let bootstrapCalls = 0; + let bootstrapZombie = true; + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (pathnameOf(input) === "/opencodex-session") { + bootstrapCalls += 1; + // Zombie: never settles AND ignores the abort signal. + if (bootstrapZombie) return new Promise(() => {}); + return MINTED(); + } + const key = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)).get("X-OpenCodex-API-Key"); + if (key === "ocx_session_fresh") return new Response("{}", { status: 200 }); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + const first = await fetch("/api/config"); + expect(first.status).toBe(401); + expect(bootstrapCalls).toBe(1); + expect(promptCalls).toBe(0); + + bootstrapZombie = false; + const second = await fetch("/api/config"); + expect(second.status).toBe(200); + expect(bootstrapCalls).toBe(2); +}); From 1b823d3332d478058fa6248e06a77c6b8446f437 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 16 Aug 2026 23:25:36 +0900 Subject: [PATCH 3/4] fix(gui): scope the resolution watchdog to the bootstrap, never the prompt Review round r3 (FAIL, one High) caught it: a whole-body watchdog fires 15s into a user-controlled admin-token prompt on non-loopback dashboards, and since promptForAdminToken has no singleton guard each subsequent poll wave stacked another modal, with cancel poisoning promptCancelled. The watchdog now races only the bootstrap call inside the shared body; while the prompt pends, later waves join the same body (single dialog). New regression test: watchdog never bounds the prompt, waves join, single prompt across a >watchdog window. --- .../020_phase2_auth_unwedge.md | 18 +++++++++ gui/src/api.ts | 38 ++++++++++-------- gui/tests/api-auth-deadline.test.ts | 40 +++++++++++++++++++ 3 files changed, 80 insertions(+), 16 deletions(-) diff --git a/devlog/_plan/260816_gui_loading_performance/020_phase2_auth_unwedge.md b/devlog/_plan/260816_gui_loading_performance/020_phase2_auth_unwedge.md index ed4f02d017..4e5e0820e9 100644 --- a/devlog/_plan/260816_gui_loading_performance/020_phase2_auth_unwedge.md +++ b/devlog/_plan/260816_gui_loading_performance/020_phase2_auth_unwedge.md @@ -85,6 +85,24 @@ After — signature `resolveTokenAfter401(failedToken: string | null, callerSign Note the shared async body takes NO caller signal (a dead caller must not kill the join for the others); only the re-bootstrap's own timeout bounds it. +### 3b. Bootstrap watchdog (added in B after live verification; scoped in A re-audit) + +Live fault injection found a residual the 10s fetch bound misses: a bootstrap fetch +that never honors the client abort leaves the shared body pending forever and +re-wedges every waiter. A 15s watchdog (`resolutionWatchdogMs`) therefore races +THE BOOTSTRAP CALL inside the body and resolves `{ kind: "failed" }` on a win. + +Scope discipline (round-3 audit): the watchdog must NEVER race the admin-token +prompt. The prompt is user-controlled and unbounded; while its body pends, later +waves join the same `resolutionInFlight`, which is what keeps exactly one dialog +on screen (promptForAdminToken has no singleton guard). A whole-body watchdog +fires at 15s mid-prompt and stacks a fresh modal every cycle on non-loopback +dashboards — the exact configuration the prompt exists for. Regression test: +api-auth-deadline "the watchdog never bounds the prompt". + +The conditional clear (`resolutionInFlight === tracked`) stays: a late settle of +an abandoned body must not wipe a newer in-flight resolution. + ### 4. Thread the caller signal through installApiAuthFetch (~line 191-226) - Extract `const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : undefined)` diff --git a/gui/src/api.ts b/gui/src/api.ts index 10aa7e546d..658beac1ff 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -39,6 +39,12 @@ let rebootstrapTimeoutMs = SESSION_REBOOTSTRAP_TIMEOUT_MS; * pends without settling, any surprise inside the shared body. Without it one stuck * resolution pins every /api/* waiter for the page lifetime, which is the exact * failure this module exists to kill. + * + * Scope note: the watchdog races the BOOTSTRAP CALL ONLY, never the admin-token + * prompt. The prompt is user-controlled and unbounded by design; while its body + * pends, later waves join the same resolution, which is what keeps a single dialog + * on screen (promptForAdminToken has no singleton guard — a watchdog that fired + * during the prompt would stack a fresh modal every cycle). */ const RESOLUTION_WATCHDOG_MS = 15_000; let resolutionWatchdogMs = RESOLUTION_WATCHDOG_MS; @@ -208,12 +214,23 @@ async function resolveTokenAfter401(failedToken: string | null, callerSignal?: A const current = readToken(); if (current && current !== failedToken) return current; - const renewed = await reBootstrapSessionToken(); + // The watchdog races the bootstrap call only — never the prompt below. When + // it wins, the wave fails and the conditional clear lets the NEXT 401 start + // a fresh resolution instead of joining the zombie. + let watchdog: ReturnType | undefined; + const renewed = await Promise.race([ + reBootstrapSessionToken(), + new Promise((resolve) => { + watchdog = setTimeout(() => resolve({ kind: "failed" }), resolutionWatchdogMs); + }), + ]).finally(() => clearTimeout(watchdog)); if (renewed.kind === "minted") return renewed.token; // Transient bootstrap failure: this wave fails and the next 401 re-arms a // fresh resolution (the finally clears resolutionInFlight). No prompt. if (renewed.kind === "failed") return null; + // User-controlled and unbounded: later waves join this pending body, which + // is what keeps exactly one prompt dialog on screen. const prompted = await requestAdminToken(verifyAdminToken); if (prompted) { storeToken(prompted); @@ -222,21 +239,10 @@ async function resolveTokenAfter401(failedToken: string | null, callerSignal?: A promptCancelled = true; return null; })(); - // The watchdog loses the race to a healthy resolution by seconds; when it wins, - // waiters unwrap as a failed wave and the conditional clear lets the NEXT 401 - // start a fresh resolution instead of joining the zombie. - let watchdog: ReturnType | undefined; - const watched = Promise.race([ - body, - new Promise((resolve) => { - watchdog = setTimeout(() => resolve(null), resolutionWatchdogMs); - }), - ]); - const tracked = watched.finally(() => { - clearTimeout(watchdog); - // Only clear if nobody replaced us — a zombie body settle late must not - // wipe a newer in-flight resolution. (Async callback: tracked is assigned - // long before this can run.) + const tracked = body.finally(() => { + // Only clear if nobody replaced us — a late settle must not wipe a newer + // in-flight resolution. (Async callback: tracked is assigned long before + // this can run.) if (resolutionInFlight === tracked) resolutionInFlight = null; }); resolutionInFlight = tracked; diff --git a/gui/tests/api-auth-deadline.test.ts b/gui/tests/api-auth-deadline.test.ts index 754f3b792d..de4520ad88 100644 --- a/gui/tests/api-auth-deadline.test.ts +++ b/gui/tests/api-auth-deadline.test.ts @@ -224,3 +224,43 @@ test("a signal-dropping hung bootstrap is bounded by the whole-resolution watchd expect(second.status).toBe(200); expect(bootstrapCalls).toBe(2); }); +test("the watchdog never bounds the prompt: slow user input stacks no dialogs and waves join", async () => { + // Non-loopback shape: the bootstrap definitively refuses (401 -> unavailable), so + // resolution escalates to the prompt. The prompt is user-controlled: the watchdog + // must NOT fire around it, and later 401 waves must join the pending body instead + // of opening another dialog (promptForAdminToken has no singleton guard). + setRebootstrapTimeoutForTests(50); + setResolutionWatchdogForTests(120); + let bootstrapCalls = 0; + let releasePrompt: ((token: string) => void) | null = null; + resetApiAuthFetchForTests(async () => { + promptCalls += 1; + return new Promise((resolve) => { + releasePrompt = resolve; + }); + }); + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (pathnameOf(input) === "/opencodex-session") { + bootstrapCalls += 1; + return new Response("unauthorized", { status: 401 }); + } + const key = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)).get("X-OpenCodex-API-Key"); + if (key === "manual-admin-token") return new Response("{}", { status: 200 }); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + const a = fetch("/api/config"); + await new Promise((resolve) => setTimeout(resolve, 30)); + const b = fetch("/api/providers"); + // Sit past the watchdog window: the pending prompt must hold both waves. + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(promptCalls).toBe(1); + expect(bootstrapCalls).toBe(1); + + releasePrompt!("manual-admin-token"); + const [resA, resB] = await Promise.all([a, b]); + expect(resA.status).toBe(200); + expect(resB.status).toBe(200); + expect(promptCalls).toBe(1); +}); From 2511302f073fc755d1d429d435b1441d475d8685 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 16 Aug 2026 23:30:42 +0900 Subject: [PATCH 4/4] docs(devlog): record WP2 landing + verification evidence (020 D addendum) --- .../020_phase2_auth_unwedge.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/devlog/_plan/260816_gui_loading_performance/020_phase2_auth_unwedge.md b/devlog/_plan/260816_gui_loading_performance/020_phase2_auth_unwedge.md index 4e5e0820e9..9838d79e60 100644 --- a/devlog/_plan/260816_gui_loading_performance/020_phase2_auth_unwedge.md +++ b/devlog/_plan/260816_gui_loading_performance/020_phase2_auth_unwedge.md @@ -163,3 +163,25 @@ the abort-race branch; browser check repeats 001/E2 end-to-end. ## Out of scope (WP2) Server-side session TTL changes; token UX redesign; /v1/* proxy-path auth. + +## D addendum — landed (2026-08-16) + +Implementation: commits 4f489ddf9 (bounded tri-state bootstrap + abort-aware +resolution), 6ced61d45 (bootstrap watchdog + 001/E3 harness-artifact correction), +edc51e6b2 (watchdog scoped off the prompt — round-3 audit fix). + +Verification evidence: +- `cd gui && bun test tests/api-auth-deadline.test.ts tests/api-auth-memory.test.ts tests/admin-token-dialog.test.ts tests/bounded-fetch.test.ts` + → 23 pass / 0 fail (6 new deadline cases: hung-bootstrap re-arm, no-prompt on + timeout/5xx, per-caller abort unwind with listener-balance spy, signal carry on + retry, signal-dropping zombie bounded by the watchdog, prompt never watchdog- + bounded with single-dialog join). +- `cd gui && bun run build` → green. +- Live E2 (in-app browser, CDP Fetch injection on a sandboxed instance): 401 storm + + bootstrap stalled past every bound → poll waves settle and re-arm (~15s + cycle); after the injection clears, EVERY tab recovers automatically with no + reload (full 5s poll wave of 200s, dashboard exits the cannot-connect state). + Harness note: the raw CDP channel rejects Fetch.disable — interception is + cleared with Fetch.enable + empty patterns; earlier "post-heal silence" + observations were that artifact, recorded as a correction in 001/E3. +- Audit: binding rounds r3 (FAIL → fixed) and r4 (PASS, fresh).