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/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..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 @@ -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)` @@ -145,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). diff --git a/gui/src/api.ts b/gui/src/api.ts index 1df043791b..658beac1ff 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,29 @@ 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; + +/** + * 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. + * + * 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; + function needsApiAuth(input: RequestInfo | URL): boolean { try { const raw = input instanceof Request ? input.url : String(input); @@ -102,23 +126,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 +205,61 @@ 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) { + const body = (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; + // 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; - const renewed = await reBootstrapSessionToken(); - if (renewed) return renewed; + // 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); + return prompted; + } + promptCancelled = true; + return null; + })(); + 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; + } - 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 +273,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 +290,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); @@ -235,4 +310,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 new file mode 100644 index 0000000000..de4520ad88 --- /dev/null +++ b/gui/tests/api-auth-deadline.test.ts @@ -0,0 +1,266 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { + installApiAuthFetch, + resetApiAuthFetchForTests, + setRebootstrapTimeoutForTests, + setResolutionWatchdogForTests, +} 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); + setResolutionWatchdogForTests(15_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); +}); +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); +}); +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); +});