diff --git a/devlog/_plan/260816_gui_loading_performance/000_plan.md b/devlog/_plan/260816_gui_loading_performance/000_plan.md new file mode 100644 index 0000000000..cd068ea1a1 --- /dev/null +++ b/devlog/_plan/260816_gui_loading_performance/000_plan.md @@ -0,0 +1,92 @@ +# GUI loading resilience + performance campaign — plan + +Date: 2026-08-16. Session: 01a00a59-96db-72a0-a12d-c8b9639e5607 (cxc-loop HOTL, +goalplan slug: opencodex-gui-dashboard-performance-campaign-fix). + +## Objective + +1. Kill the "infinite loading" wedge: any tab can sit on a skeleton forever until F5, + then re-wedge later. Root-caused below with live fault-injection evidence. +2. Per-tab performance: fewer timers, fewer redundant requests, no unbounded fetches. +3. Hidden tab ≈ zero cost: every poll/timer in the dashboard pauses while + `document.visibilityState === "hidden"` and resumes (with one make-up refresh) on + visible. Only explicit opt-outs keep running (restart-reconnect detection). + +## Constraints + +- Worktree: /Users/jun/.codex/worktrees/f9b0/opencodex (detached at origin/dev tip + b81314cd2 when started). Branches: codex/gui-* stack, PRs target dev / parent branch. +- The user's running ocx (port 10100) is never touched. All live verification runs + against a sandboxed instance (OPENCODEX_HOME=/tmp/ocx-gui-perf/home, port 10199) + behind a Vite dev server (port 5199, OPENCODEX_PROXY_TARGET). +- Commits/pushes use --no-verify (user-authorized). Push + PR + admin merge + pre-authorized by the user for this campaign. Merges bottom-up via + `gh pr merge --merge --match-head-commit `. +- Full suite runs on ssh lidge only (bun test --isolate tests); local gates are + focused gui tests, `cd gui && bun run build`, `bun run typecheck` at PR boundaries. +- gui/AGENTS.md: no new hardcoded UI strings without i18n keys; no new dependency for + behavior the stack can provide; gui/dist is generated. + +## Root cause (evidence: 001_repro_evidence.md) + +H1 — client-resource fetch path has no deadline. A hung request leaves +`refreshing:true` (cold keys also `loading:true`) forever: poll ticks skip while +`inflight` is set (client-resource.ts:212), the visibility make-up fetch skips too +(:192), and only manual refresh/unmount aborts (:214, :327). Measured: a stalled +/api/settings produced ZERO retries over 12s+ while its 5s poll tick fired. + +H2 — the 401 re-bootstrap is an abort-proof, page-lifetime, app-wide chokepoint. +`resolveTokenAfter401` shares one `resolutionInFlight` promise (api.ts:165-189) that +awaits `reBootstrapSessionToken` → `rawFetch("/opencodex-session")` with no timeout +and no caller signal (api.ts:108-123). Loopback sessions expire every 5 minutes +(api.ts:101-107 design comment), so the wedge re-arms periodically. Measured: with the +bootstrap stalled and the server otherwise healthy, /api/* traffic dropped to ZERO for +40s+ while /healthz kept polling; cold tabs showed permanent skeletons (screenshot +archived in 001). Bonus finding: a non-polled store caught mid-wedge (Storage) never +recovered even after the network healed — nothing ever refires its request. + +H5 (secondary) — raw `setInterval` pollers outside client-resource +(Debug 1s, ProviderSettings 2s, CodexAuth 30s, CodexAccountPickerSetting 30s, +DefaultModeRequestUserInputSetting 30s, Models loadV2 10s, useCodexAccountPool) have +no visibility handling, mostly no in-flight guard and no timeout. They keep firing in +hidden tabs and can stack hung requests. + +Performance baseline (measured, CDP Network domain, 31s on Dashboard, idle sandbox): +48 API requests — 9 endpoints polled at ~5s cadence each plus /healthz x6. Full +per-tab inventory: 002_polling_inventory.md. + +## Work-phase map (dependency-ordered, PHASE-SPLIT-01; stack plan DEV-STACK-01) + +| WP | Decade doc | Layer (branch) | Proves on its own | +|----|-----------|----------------|-------------------| +| 1 | 010_phase1_resource_deadline.md | codex/gui-resource-deadline (base dev) | bounded fetch + wedge recovery in client-resource; tests | +| 2 | 020_phase2_auth_unwedge.md | codex/gui-auth-unwedge (base WP1) | 401/re-bootstrap can no longer pin the page; tests | +| 3 | 030_phase3_hidden_pause.md | codex/gui-hidden-pause (base WP2) | hidden tab ≈ zero fetch/timer activity; tests + measurement | +| 4 | 040_phase4_poll_consolidation.md | codex/gui-poll-consolidation (base WP3) | shared tick scheduler + re-activation revalidation; request-count delta | + +Dependency rationale: WP1 is the data-path foundation every later layer's tests rely +on; WP2 touches only api.ts but its abort-threading composes with WP1's new signals; +WP3 builds the visibility ticker on the settled WP1 semantics; WP4 reshapes polling +on top of WP3's visibility-aware scheduler. Lower layers are mergeable alone: each +ships its own tests and stands green at its own tip. + +Non-goals: server-side endpoint merging, gui/dist edits, release/version changes, +touching the running instance, redesigning page-level UX beyond error/loading states +that already exist in data-surface. + +## Verifiers (all run at least once before each C>D) + +- `cd gui && bun test tests/client-resource-poll.test.tsx tests/.tsx` (focused) +- `cd gui && bun run build` (tsc -b && vite build — browser/bundler gate) +- `bun run typecheck` (root, at PR boundary) +- `cd gui && bun run lint` (oxlint, at PR boundary) +- Browser: in-app browser against localhost:5199 with CDP fault injection + (repeat of 001 scenarios must now recover without reload) +- Remote: ssh lidge 'cd ~/Developer/opencodex && git fetch && git checkout + && bun test --isolate tests' before final DONE claim. + +## Expected terminal outcomes + +DONE = all four PRs merged into dev, lidge isolated suite green, browser repro +scenarios recover without reload, hidden-tab request rate ≈ 0. BLOCKED/NEEDS_HUMAN +reported with evidence if any gate cannot run. diff --git a/devlog/_plan/260816_gui_loading_performance/001_repro_evidence.md b/devlog/_plan/260816_gui_loading_performance/001_repro_evidence.md new file mode 100644 index 0000000000..822c315201 --- /dev/null +++ b/devlog/_plan/260816_gui_loading_performance/001_repro_evidence.md @@ -0,0 +1,91 @@ +# 001 — live reproduction evidence (research, no diffs) + +Environment: sandboxed backend `bun run src/cli/index.ts start --port 10199` with +`OPENCODEX_HOME=/tmp/ocx-gui-perf/home` (isolated from the user's instance on 10100), +Vite dev server on http://localhost:5199 with `OPENCODEX_PROXY_TARGET=http://127.0.0.1:10199`. +Browser: in-app browser, raw CDP (Network + Fetch domains) for fault injection. + +## E0 — baseline request rate (performance criterion) + +All 9 nav tabs visited (~1s each), then 31s dwell on Dashboard, idle sandbox, page +otherwise untouched. CDP `Network.requestWillBeSent` count: + + 48 requests / 31s on Dashboard alone: + /api/system/memory x5 /healthz x6 /api/providers x5 /api/v2 x5 + /api/sidecar-settings x5 /api/shadow-call-settings x5 /api/settings x5 + /api/injection-model x5 /api/effort-caps x5 + /api/startup-health x1 /api/diagnostics/project-config x1 + +i.e. ~1.5 req/s steady-state on one visible tab, ~9 concurrent 5s store pollers. + +## E1 — H1: one hung request wedges its store permanently + +CDP `Fetch.enable` on `/api/settings` (XHR+Fetch resourceTypes), never continue the +paused request = a server that accepts but never answers. + +- 2 requests paused at mount (StrictMode first + live second), then ZERO further + /api/settings attempts measured over 12s and again over 40s — the 5s poll tick + skipped the wedged store every time (client-resource.ts:212). +- Store snapshot stays refreshing:true; a cold key would show its skeleton forever. + Exit paths today: F5, manual refresh(), or unmount/remount of every subscriber. + +## E2 — H2: stalled 401 re-bootstrap wedges ALL management fetches + +Injection: fulfill every /api/* with 401 (real JSON body), stall /opencodex-session +forever. Observed during the 401 wave: 3 requests fulfilled 401, 1 bootstrap request +stalled. Only 3 /api requests ever hit the network because every later call joined +the pending `resolutionInFlight` promise client-side (api.ts:167-169). + +Recovery phase: interception narrowed so /api/* flows to the REAL healthy server +and only /opencodex-session stays stalled. Measured over the next 40s: + + /healthz x2 (the 30s App poll — alive) + /api/* x0 — nothing reaches the network, nothing settles, forever + +Cold tab opened in this state (Storage): "Scanning storage…" + 11 skeleton nodes, +indefinite (screenshot: /tmp/ocx-gui-perf/shots/wedged-storage.png during session; +regenerable by re-running this scenario). This is the user-reported symptom. + +## E3 — post-heal stickiness of non-polled stores + +After fully disabling interception (network healthy again), NEW cold tabs recover +(Usage fetched /api/usage twice and rendered). The Storage store — non-polled, +cold-mounted during the wedge — NEVER refired: 0 /api/storage requests in the +following 10s+, skeleton forever. Non-polled stores have no retry path at all once +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). + +## E4 — hidden-tab emulation limit + +The in-app browser keeps background tabs `visibilityState: "visible"` (verified by +opening+selecting a second tab). Hidden-tab verification therefore runs as +happy-dom tests driving visibilityState directly (pattern already established in +gui/tests/client-resource-poll.test.tsx:64-74), not live emulation. + +## Server-side note + +`/opencodex-session` is served by a static HTML responder (src/server/gui-static.ts:102) +and is fast on a healthy loopback server; the defect is that the CLIENT has no +deadline on this app-wide critical path, so any stall (event-loop stall, proxy +restart mid-request, remote dashboard over a slow link) wedges the page. Sessions +expire every 5 minutes (api.ts:101-107), so the exposure re-arms periodically — +matching "stuck again a while after every refresh." + +## E5 — user addendum: "refresh → it loads/gets stuck AGAIN" (recurrence) + +User report (2026-08-16, mid-investigation): pressing refresh does not cure it — +the loading state comes back. Consistent with the two mechanisms above: + +1. F5 clears module state and mints a fresh session via meta tags, so the first + seconds work; the next 5-minute session expiry re-enters the 401 → re-bootstrap + path (H2), and any single hung management route re-wedges its store (H1). Refresh + resets the clock, it does not remove the mechanism. +2. The cold-mount fan-out right after F5 (~15-20 concurrent /api requests across + tabs, including endpoints documented as slow: /api/usage?range=30d ~5s cold, + Providers.tsx:99-102; live model discovery, Models.tsx:406-408) maximizes the + chance that at least one request stalls or 401s immediately, which is why the + stuck state can reappear almost immediately after a refresh. + +Implication for the fix: recovery must not depend on page lifetime or on the server +never stalling. Every request needs a deadline that settles the store, and the auth +resolution must be bounded and abort-aware — both lands in WP1/WP2. diff --git a/devlog/_plan/260816_gui_loading_performance/002_polling_inventory.md b/devlog/_plan/260816_gui_loading_performance/002_polling_inventory.md new file mode 100644 index 0000000000..d3bcbbcb67 --- /dev/null +++ b/devlog/_plan/260816_gui_loading_performance/002_polling_inventory.md @@ -0,0 +1,59 @@ +# 002 — fetch/poll inventory (research, no diffs) + +Source: independent explorer audit of gui/src at origin/dev tip, verified against +live CDP measurement (001). No EventSource/WebSocket anywhere in gui/src — all +realtime behavior is interval polling. + +## client-resource stores (pauseWhenHidden-capable) + +| Surface | File:line | Endpoint(s) | pollMs | Notes | +|---|---|---|---|---| +| App shell | App.tsx:128 | /healthz | 30s | non-gating | +| Sidebar | sidebar-github-row.tsx:63,69 | /api/github/star, /api/update/badge | 300s/600s | | +| Dashboard | use-dashboard-data.ts:199-285 | /api/startup-health 30s; /healthz+/api/providers 5s; /api/v2 5s; /api/sidecar-settings+/api/shadow-call-settings 5s; /api/settings 5s; /api/injection-model+/api/effort-caps 5s; /api/usage?range=30d; /api/diagnostics/project-config; /api/models | 5s wave | wave-2 gated on overviewReady | +| Dashboard update job | use-dashboard-data.ts:406 | /api/update/status + /healthz | 1.5s | pauseWhenHidden:false (by design) | +| Startup | Startup.tsx:206 | /api/startup-health+/api/settings(+/api/windows-tray) | none | session cache seed | +| Providers | Providers.tsx:103,113 + ProviderWorkspaceShell.tsx:178 | /api/provider-presets; /api/usage?range=30d (shared key, 4 subscribers) | none | cache warm | +| Models catalog | Models.tsx:366 | /api/models+/api/provider-context-caps+/api/providers | 10s | session cache | +| Combos | Combos.tsx:197 | /api/combos+/api/config+/api/models | none | active-gated | +| Compatibility | CompatibilityMatrix.tsx:297 | lab matrix | 60s | | +| Subagents | Subagents.tsx:133 (loader :117) | /api/subagent-models | none | loader takes NO signal | +| Logs | Logs.tsx:490 | /api/logs?limit=2000 | 2s (auto-refresh default on) | | +| Debug | Debug.tsx:42,59 | /api/debug; /api/claude/inbound-debug | 2s | + raw 1s poll below | +| Usage | Usage.tsx:775 | /api/usage?range&surface | none | | +| Storage | Storage.tsx:1370,396 | /api/storage; /api/storage/trash | none | E3 victim | +| Integrations | ApiKeys/ClaudeCode/ClaudeDesktop/Grok/IntegrationsOverview/FileIntegrationPage | 8+ keys | none (ClaudeDesktop status 5s) | active-gated, session-seeded | + +## Raw setInterval pollers (NO visibility handling today — H5) + +| File:line | Endpoint | Cadence | signal | timeout | in-flight guard | +|---|---|---|---|---|---| +| MemoryObservabilityCard.tsx:277 | /api/system/memory | 5s | yes | 10s bounded | yes | +| MemoryObservabilityCard.tsx:342 | /healthz (reconnect) | 1.5s, gives up 120s | yes | 5s bounded | n/a | +| ProviderSettings.tsx:162 | /api/provider-request-pacing | 2s | NO | NO | NO | +| CodexAccountPickerSetting.tsx:42 | /api/settings | 30s | NO | NO | n/a | +| DefaultModeRequestUserInputSetting.tsx:47 | feature endpoint | 30s | NO | NO | n/a | +| useCodexAccountPool.ts:342 | account pool load | REFRESH_INTERVAL_MS | NO (:222,:250) | NO | n/a | +| CodexAuth.tsx:154 | /api/config | 30s | NO (:123) | NO | n/a | +| Models.tsx:413 | loadV2 | 10s, v2BusyRef-gated | NO (:298) | NO | busy-ref only | +| Debug.tsx:157 | pollLogs(false) | 1s | NO (:115) | NO | NO — stacks hung requests, refreshing can stick (explorer S6) | +| use-add-codex-account-oauth.ts:177 | OAuth login-status | 2s, 300s cap | yes | 10s bounded | yes | + +## Timeouts today + +Only 4 bounded call sites exist: MemoryObservabilityCard (2), ApiKeys mutations +(15s), stop-proxy (15s), OAuth status (10s). Every client-resource fetcher (~40 +sites) and the session re-bootstrap are unbounded. + +## Shared-key hazard + +`usage-summary-30d::all` has 4 independent subscribers (Dashboard, Providers, +AddProviderModal, ProviderWorkspaceShell). One in-flight serves all — and one hang +wedges all (H1 applied to a shared key). + +## Staleness note (S7) + +Non-polled, active-gated pages (Integrations family, Combos) never revalidate on +tab re-activation: subscribe with cached data does not refetch unless +seedNeedsRevalidate (client-resource.ts:313-317). Contributes to the "stale until +F5" feel; addressed in WP4 with staleness-threshold revalidation. diff --git a/devlog/_plan/260816_gui_loading_performance/010_phase1_resource_deadline.md b/devlog/_plan/260816_gui_loading_performance/010_phase1_resource_deadline.md new file mode 100644 index 0000000000..b1d768917e --- /dev/null +++ b/devlog/_plan/260816_gui_loading_performance/010_phase1_resource_deadline.md @@ -0,0 +1,225 @@ +# 010 — WP1: client-resource request deadline + wedge recovery (H1) + +Base: `dev`. Branch: `codex/gui-resource-deadline`. Depends on: nothing. + +## Goal + +No fetch on the client-resource data path may pend forever. Every attempt gets a +deadline; on expiry the store SETTLES as a failure (never sticks in +loading/refreshing), polled stores retry on the next tick automatically, cold +non-polled stores land in the existing `failed-cold` error surface instead of an +infinite skeleton. + +## File change map + +- MODIFY `gui/src/client-resource.ts` — deadline plumbing (below). +- MODIFY `gui/src/bounded-fetch.ts` — no API change; reused by client-resource. + (Search evidence: `rg "AbortSignal.any|AbortSignal.timeout" gui/src` → bounded-fetch.ts + is the existing owner of the timeout-compose pattern; MemoryObservabilityCard and + use-add-codex-account-oauth already use it. Extending it beats a new helper.) +- NEW `gui/tests/client-resource-deadline.test.tsx` — regression tests. +- MODIFY `gui/tests/client-resource-poll.test.tsx` — only if a shared helper moves. + +No server changes. No i18n changes (no new copy: failure states reuse the existing +data-surface error banners). + +## Change spec + +(Amended after audit round 1 — blockers 1/5/6/7/11 folded in. Key design change: +the deadline is a Promise.race inside runFetch, NOT bounded-fetch composition, so +it also bounds fetchers that drop the signal and cannot re-wedge via the +manual-fallback abort path.) + +### 1. Options + constant (client-resource.ts) + +Add to `ClientResourceOptions` (after `pauseWhenHidden`, actual line :352): + + /** Per-attempt deadline. Default DEFAULT_REQUEST_DEADLINE_MS. A timed-out attempt + * settles as a failure so a hung endpoint can never wedge the store (H1). */ + deadlineMs?: number; + +Module level: + + /** Endpoints documented as slow finish in ~5s; 30s leaves generous headroom. */ + const DEFAULT_REQUEST_DEADLINE_MS = 30_000; + /** Abort reason sentinel distinguishing the deadline from owner aborts. */ + const RESOURCE_TIMEOUT = "ocx-resource-deadline"; + +### 2. Per-listener options object (replaces positional sprawl — audit #11) + +`subscribeResource` currently positional (key, fetcher, pollMs, onStoreChange, +pauseWhenHidden) becomes: + + type ListenerRegistration = { + fetcher: (signal: AbortSignal) => Promise; + pollMs?: number; + pauseWhenHidden?: boolean; + deadlineMs?: number; + }; + subscribeResource(key, onStoreChange, registration: ListenerRegistration) + +The per-listener maps (pollByListener / pauseWhenHiddenByListener / +fetcherByListener) gain `deadlineByListener`; all are set/cleared together in +registration (~:304-308) and teardown (~:322-326). Where several subscribers share +one key with different values, first-registrant-wins is NOT the rule — the pickers +read the CHOSEN listener's value per fetch (existing per-listener semantics, +extended; see §6 for why shared keys still get uniform overrides). + +### 3. Entry pickers carry the deadline + +`pickPollEntry` / `pickFetcherEntry` return type gains +`deadlineMs: number | undefined` read from `deadlineByListener` for the chosen +listener. + +### 4. runFetch: deadline via Promise.race + abort-reason sentinel (core change) + +Before (~:208-260): `const controller = new AbortController(); store.inflight = +controller; await fetcher(controller.signal)` — no deadline; the settle guards read +`controller.signal.aborted`. + +After: + + const controller = new AbortController(); + store.inflight = controller; + const gen = ++store.generation; + let timedOut = false; + const deadlineMs = options?.deadlineMs ?? DEFAULT_REQUEST_DEADLINE_MS; + const timer = setTimeout(() => { timedOut = true; controller.abort(RESOURCE_TIMEOUT); }, deadlineMs); + try { + const data = await Promise.race([ + fetcher(controller.signal), + new Promise((_, reject) => { + controller.signal.addEventListener("abort", () => { + if (timedOut) reject(new Error(`resource request timed out after ${deadlineMs}ms`)); + else resolve(null as never); // owner abort frees the race frame immediately (round 2) + }, { once: true }); + }), + ]); + ...existing success settle... + } catch (error) { + if (gen !== store.generation) return; + if (controller.signal.aborted && !timedOut) return; // owner abort: replace/unmount — unchanged + ...existing failure settle (timeout lands here: error/loading:false/refreshing:false/lastAttemptOk:false)... + } finally { + clearTimeout(timer); + ...existing inflight clear + emit... + } + +Why race instead of bounded-fetch composition (audit #1 + #5): +- The manual fallback of createBoundedFetch aborts the SAME controller the guards + read, so a timeout would early-return and never settle (re-wedge). The + `timedOut` flag + reason sentinel make timeout and owner-abort distinguishable on + every runtime, no AbortSignal.any dependency. +- Fetchers that drop the signal (Subagents.tsx:117) cannot be aborted — but the + race still settles the store failed at the deadline; the orphaned fetch is + discarded by the generation guard when it eventually lands. + +### 5. Callers plumb the deadline — COMPLETE list (audit #7) + +- subscribeResource cold-start fetch (:313-316) — the registering listener's value. +- poll tick (interval callback, :169-174) — entry.deadlineMs from pickPollEntry. +- visibility make-up fetch (:185-204) — entry.deadlineMs from pickFetcherEntry. +- unsubscribe-replacement fetch (:336) — entry.deadlineMs from pickFetcherEntry. +- `refresh()` (:411-418) — the refreshing listener's own deadlineMs (read via + listenerRef from deadlineByListener, not a frozen option). +- `useClientResource` passes options.deadlineMs into the registration and includes + it in the subscribe useCallback deps; `useDataSurface` gains the + `DataSurfaceOptions.deadlineMs` pass-through (data-surface.ts:39-48). + +### 6. Known-slow overrides — at ALL subscribers of shared keys (audit #6) + +`pickFetcherEntry` picks the first registered listener, so an override on one +subscriber of a shared key is mount-order-dependent. Set `deadlineMs: 60_000` at +EVERY subscriber of `usage-summary-30d::all`: +use-dashboard-data.ts:~268, Providers.tsx:~114, AddProviderModal.tsx:~83, +ProviderWorkspaceShell.tsx:~178. And `deadlineMs: 60_000` at the Models catalog +resource (Models.tsx useDataSurface at :379; live discovery documented slow at +:406-408). Everything else takes the 30s default. + +### 7. Signal-drop audit (audit #5) + +`rg "fetch(" gui/src/pages gui/src/components gui/src/hooks` cross-checked against +client-resource loader functions; every loader must pass the signal. Confirmed +offender today: Subagents.tsx:117 → thread `signal` into its fetch (same line). Any +further offenders found in B are fixed in the same commit and listed in D. + +## Behavior after + +| Scenario | Before | After | +|---|---|---| +| Hung endpoint, polled store | skeleton/spinner forever, ticks skip (E1) | settles failed ≤ deadline; next tick retries; self-heals | +| Hung endpoint, cold non-polled store | infinite skeleton (E3) | `failed-cold` error surface with the page's retry affordance | +| Slow-but-healthy endpoint (< deadline) | eventually succeeds | unchanged | +| Unmount/replace mid-flight | abort, no settle | unchanged (abort path untouched) | +| Signal-dropping loader (Subagents) | wedge even with deadline | race settles the store; orphan discarded by generation guard | + +Accepted terminal state (audit extra): a MOUNTED non-polled store that settles +failed does not auto-refetch when the network heals (no poll tick) — the user +retries via the surface's existing refresh control (Storage.tsx:1410 renders it on +showError). Auto-heal on re-activation is WP4's staleAfterMs, not WP1. + +## Tests (NEW gui/tests/client-resource-deadline.test.tsx) + +Harness: copy the happy-dom Window + act() + waitFor pattern from +client-resource-poll.test.tsx (globals swap, clearClientResourceStoresForTests). + +1. `never-settling fetcher settles failed within the deadline` — fetcher returns a + promise that never resolves, deadlineMs: 50; waitFor snapshot.lastAttemptOk === + false && error instanceof Error && loading === false && refreshing === false. +2. `polled store self-heals after a timed-out attempt` — attempt 1 never settles, + attempts 2+ resolve "ok"; pollMs: 40, deadlineMs: 50; waitFor data === "ok" + with zero manual refresh calls. Assert fetcher call count >= 2. +3. `timed-out cold store shows the error surface, not a skeleton` — drive + classifyDataSurface on the settled snapshot: kind === "failed-cold", + showSkeleton false, showError true. +4. `unmount during the deadline window still takes the abort path` — mount with a + never-settling fetcher, unmount before deadline, assert no failure settle + stomps a remounted subscriber (abort semantics regression guard). +5. `manual refresh after timeout recovers immediately` — after (1), call refresh() + with a resolving fetcher; waitFor data. +6. `signal-dropping fetcher is bounded by the race` (audit #5) — fetcher that + never resolves AND ignores the signal still settles failed at the deadline. +7. `owner abort after the deadline fired cannot masquerade as failure` (audit #1 + guard semantics) — deadline fires, then a replace/unmount abort lands before the + rejection propagates: exactly one settle, and it is the timeout failure; a + subsequent owner-abort of a HEALTHY in-flight request never settles failed. + +Activation scenarios (C-ACTIVATION-GROUNDING-01): the deadline path is the new +conditional branch — tests 1/2/3 trigger it for real with a never-settling fetcher +and observe the settle; the live browser check repeats 001/E1 and observes +recovery without reload. + +## Verifiers + +- `cd gui && bun test tests/client-resource-deadline.test.tsx tests/client-resource-poll.test.tsx tests/data-surface.test.tsx` +- `cd gui && bun run build` +- Browser: repeat 001 E1 (stall /api/settings): the affected card must reach an + error/settled state ≤ 30s and resume polling; no permanent skeleton. + +## Out of scope (WP1) + +Auth-path deadline (WP2), visibility clearing (WP3), interval consolidation (WP4), +fetcher-resolves-undefined normalization (S5 — noted, no live offender). + +## D addendum — landed (2026-08-16) + +Implementation: commits 0e5194cff (all §1-§7 changes + tests) and c704eecf5 +(indent nit from the implementation review). + +Verification evidence: +- `cd gui && bun test tests/client-resource-deadline.test.tsx` → 7 pass / 0 fail; + combined with client-resource-poll, data-surface, page-loading-contract, + storage-loading-race, startup-usage-loading-race, startup-revisit-cache, + models-*, providers/add-provider/subagents suites → 70 pass / 0 fail total. +- `cd gui && bun run build` → green (tsc -b + vite). +- Browser E1 re-run (CDP stall on /api/settings): under the old code the wedged + store produced ZERO retries; with the deadline the store settles and retries on + a measured 35s cycle (request timestamps: +34.96s, +35.0s = 30s deadline + 5s + poll tick), and a reload showed the full healthy 5s poll wave. +- Implementation audit: binding review round r2 (explorer subagent) — PASS, no + High/Critical; accepted Low: usage-summary:codex keeps the 30s default (single + subscriber, still bounded). + +Deviation from spec: none in mechanism. The race's abort-listener else-resolve +(round-2 audit addition) is what frees owner-aborted frames for signal-dropping +fetchers; confirmed covered by test 4's unmount path. 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 new file mode 100644 index 0000000000..ed4f02d017 --- /dev/null +++ b/devlog/_plan/260816_gui_loading_performance/020_phase2_auth_unwedge.md @@ -0,0 +1,147 @@ +# 020 — WP2: 401 re-bootstrap deadline + abort-aware resolution (H2) + +Base: `codex/gui-resource-deadline` (WP1). Branch: `codex/gui-auth-unwedge`. + +## Goal + +The session re-bootstrap can never pin the page. The silent bootstrap fetch gets a +deadline; the shared `resolutionInFlight` wait becomes per-caller abort-aware; a +failed/timed-out resolution clears so later requests retry instead of joining a +poisoned page-lifetime promise. Measured target: repeat 001/E2 — after the +bootstrap deadline expires, /api/* traffic resumes against the healthy server with +NO reload. + +## File change map + +- MODIFY `gui/src/api.ts` — all changes below (single-file unit). +- NEW `gui/tests/api-auth-deadline.test.ts` — regression tests (harness modeled on + tests/api-auth-memory.test.ts, which already stubs rawFetch via + resetApiAuthFetchForTests). + +No UI copy, no server changes. `promptForAdminToken` UX unchanged. + +## Change spec (gui/src/api.ts) + +### 1. Constants + test hook + +```ts +/** Silent re-bootstrap must fail fast: every /api/* request queues behind it (H2). */ +const SESSION_REBOOTSTRAP_TIMEOUT_MS = 10_000; +``` + +Module-level `let rebootstrapTimeoutMs = SESSION_REBOOTSTRAP_TIMEOUT_MS;` and extend +`resetApiAuthFetchForTests` to also restore this default; add +`setRebootstrapTimeoutForTests(ms)` (same test-only pattern as the existing reset). + +### 2. Bounded bootstrap with a TRI-STATE result (audit #2 — the load-bearing fix) + +Before: `rawFetch(SESSION_REBOOTSTRAP_PATH, { cache: "no-store" })` — no timeout, +no signal; and `null` from this function means "server won't mint" → prompt. + +After: `reBootstrapSessionToken(): Promise<"minted" | "unavailable" | "failed">` +(no caller-signal param — audit #12: the shared body must not take any single +caller's signal): + +- The rawFetch carries the bounded signal via `createBoundedFetch(rebootstrapTimeoutMs)` + (bounded-fetch.ts — the existing owner of the timeout-compose pattern). +- `"minted"` — session stored (existing storeSession success). +- `"unavailable"` — response.ok but no valid session meta, or a definitive refusal + (4xx): the server will not mint → the prompt fallback remains for non-loopback + dashboards. (Same reachability as today's null.) +- `"failed"` — timeout, abort, or network error: TRANSIENT. Must NOT fall through + to the prompt (today it would: on a loopback dashboard a 10s proxy hiccup would + pop an admin-token modal — new user-facing regression the audit caught). +- Classification totality (round 2): any non-ok that is NOT a definitive 4xx + refusal — 5xx included (transient 502/503 behind an intermediate proxy) — maps to + `"failed"`. Nothing but 4xx/ok-without-meta may reach the prompt. + +resolveTokenAfter401's body maps the tri-state: `minted` → return token; +`unavailable` → existing prompt path; `failed` → return null for THIS wave (the +finally clears resolutionInFlight, so the next 401 re-arms a fresh bootstrap). + +### 3. Abort-aware shared resolution (resolveTokenAfter401, ~line 165-189) + +Before: all callers `await resolutionInFlight`; the caller's signal is never +consulted, so a store-side abort (unmount, or WP1's deadline) cannot unwind the wait. + +After — signature `resolveTokenAfter401(failedToken: string | null, callerSignal?: AbortSignal)`: + + if (callerSignal?.aborted) return null; + if (promptCancelled) return null; + if (!resolutionInFlight) resolutionInFlight = (async () => { ...tri-state body from §2... + })().finally(() => { resolutionInFlight = null; }); + if (!callerSignal) return resolutionInFlight; + // Per-caller race: an abort unwinds THIS caller only; the shared resolution + // continues for the others. The abort listener is removed in finally so a + // resolution-won race never leaks one listener per request (audit #12). + 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); }); + +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. + +### 4. Thread the caller signal through installApiAuthFetch (~line 191-226) + +- Extract `const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : undefined)` + at the top of the wrapped fetch. +- Pass it to `resolveTokenAfter401(token, callerSignal)`. +- When resolution returns null, return the original 401 response (existing behavior) + — resource fetchers then throw via readJsonOrThrow and the store settles failed + (WP1 semantics) instead of pending forever. +- Retry attempts already carry the signal through init spread; assert in tests. + +## Behavior after + +| Scenario | Before | After | +|---|---|---| +| Bootstrap hangs, 401 wave | whole app pends forever, /healthz alive (E2) | bootstrap times out ≤10s → shared resolution settles null → callers return the 401 → fetchers throw → stores settle failed (~10s + settle, NOT 40s — the bootstrap timeout fires long before WP1's 30s store deadline; audit #10) → next poll retries and recovers; no reload | +| Transient bootstrap failure on loopback | (with naive timeout) admin-token modal storm | "failed" never reaches the prompt (audit #2) | +| Caller aborted while awaiting resolution (unmount / WP1 deadline) | wait ignores abort | that caller unwinds; shared resolution continues for others | +| Bootstrap slow (<10s) | works | unchanged | +| Non-loopback (no session mint) | prompt fallback | unchanged — prompt path has no time bound (user-controlled), only caller-abort unwind | + +## Tests (NEW gui/tests/api-auth-deadline.test.ts) + +Stub `rawFetch` via resetApiAuthFetchForTests pattern; window.fetch installed by +installApiAuthFetch. Drive requests to `/api/x`. + +1. `hung bootstrap fails the request within the deadline and clears the shared + resolution` — rawFetch: /api/* → 401 once; /opencodex-session → never resolves. + setRebootstrapTimeoutForTests(50). First fetch rejects/returns 401 within ~ms; + then rawFetch for /opencodex-session switches to a resolving mint and a SECOND + /api fetch succeeds — proving resolutionInFlight cleared and re-armed. +2. `bootstrap timeout never opens the admin-token prompt` (audit #2) — prompt spy + installed via resetApiAuthFetchForTests(prompt); after (1)'s timeout wave the + spy has ZERO calls; a 502 bootstrap response also yields ZERO prompt calls + (round-2 totality case); only an "unavailable" mint refusal (4xx / ok-without-meta) + opens it (separate assert). +3. `caller abort during pending resolution unwinds only that caller` — two fetches + join one resolution (bootstrap pends); abort caller A → A settles (401/error) + while B still pends; then bootstrap mints → B succeeds. Leak assert (round 2): + monkey-patch A's signal addEventListener/removeEventListener spies BEFORE the + fetch and assert balanced calls after the race settles (EventTarget exposes no + listener enumeration). +4. `no page-lifetime poisoning` — after (1)'s timeout, a third request triggers + exactly one NEW bootstrap call (count asserts). +5. `retry carries the caller signal` — after a successful resolution, the retried + /api request's init.signal is the caller's (spy on rawFetch args). + +Activation scenarios: test 1 triggers the timeout branch for real; test 2 triggers +the abort-race branch; browser check repeats 001/E2 end-to-end. + +## Verifiers + +- `cd gui && bun test tests/api-auth-deadline.test.ts tests/api-auth-memory.test.ts tests/admin-token-dialog.test.ts` +- `cd gui && bun run build` +- Browser: 001/E2 scenario — with the bootstrap stalled, tabs reach settled error + states ≤ ~10s + one poll tick (bootstrap timeout, audit #10 correction); + unstalling the bootstrap recovers all tabs without reload. + +## Out of scope (WP2) + +Server-side session TTL changes; token UX redesign; /v1/* proxy-path auth. diff --git a/devlog/_plan/260816_gui_loading_performance/030_phase3_hidden_pause.md b/devlog/_plan/260816_gui_loading_performance/030_phase3_hidden_pause.md new file mode 100644 index 0000000000..d5b3d151cd --- /dev/null +++ b/devlog/_plan/260816_gui_loading_performance/030_phase3_hidden_pause.md @@ -0,0 +1,171 @@ +# 030 — WP3: hidden tab ≈ zero cost + +Base: `codex/gui-auth-unwedge` (WP2). Branch: `codex/gui-hidden-pause`. +(Amended after audit round 1 — blockers #4/#8/#9 folded in.) +(Round 2: pollSuspended teardown-clear + listener-installation rule folded in.) + +## Goal + +While `document.visibilityState === "hidden"` the dashboard does (a) zero network +requests and (b) zero interval wakeups, except explicit opt-outs whose purpose is +off-screen detection (restart-reconnect). On return: one make-up refresh per +resource, then normal cadence. Measured target: CDP request count over 30s hidden +== only opt-out endpoints (today: raw pollers keep firing; client-resource timers +keep waking even when every tick is skipped). + +## File change map + +- MODIFY `gui/src/client-resource.ts` — suspend/resume poll timers on visibility. +- NEW `gui/src/visibility-poll.ts` — shared visibility-aware interval helper. + Search evidence: `rg "setInterval" gui/src` → 9 raw pollers each hand-roll + setInterval/clearInterval; no shared helper exists (`rg -l "visibilitychange" + gui/src` → only client-resource.ts). One new tiny module replaces nine copies of + the pattern; no existing owner to extend. +- MODIFY the raw pollers (migration, all mechanical): + `gui/src/pages/Debug.tsx` (:157, + in-flight guard + signal at :115), + `gui/src/components/provider-workspace/ProviderSettings.tsx` (:162, + signal/bounded at :156), + `gui/src/pages/CodexAuth.tsx` (:154, + signal at :123), + `gui/src/components/CodexAccountPickerSetting.tsx` (:42, + signal at :24), + `gui/src/components/DefaultModeRequestUserInputSetting.tsx` (:47, + signal at :32), + `gui/src/pages/Models.tsx` (:413 loadV2 loop, + signal at :298), + `gui/src/hooks/useCodexAccountPool.ts` (:342 interval, + signals at :222/:250), + `gui/src/components/use-add-codex-account-oauth.ts` (:177 OAuth login-status 2s → + migrate to the helper; signal/bound/guard already exist so it is mechanical — + audit #9; a hidden tab cannot complete OAuth and the visible make-up tick checks + status immediately on return), + `gui/src/components/MemoryObservabilityCard.tsx` (:277 memory 5s poll → pause when hidden; + :342 reconnect loop stays visible-agnostic, documented below). +- NEW `gui/tests/visibility-poll.test.ts`. +- MODIFY `gui/tests/client-resource-poll.test.tsx` — extend the existing hidden-skip + tests (fetch count frozen while hidden; churn + mount-hidden guards; make-up on + visible), reusing its setVisibility helper (:64-74). + +## Change spec + +### 1. client-resource: real suspension (not just skipped ticks) + +Store gains `pollSuspended: boolean`. NEW `suspendPollTimer(store)` clears ONLY +the timer and sets the flag — unlike `clearPollTimer` it KEEPS +`store.pollIntervalMs`, because clearing it would let hidden-phase subscriber churn +(StrictMode, tab mounts) re-arm a fresh interval through recomputePoll's +changed-interval path (audit #4). + +`ensureVisibilityListener`'s handler becomes bidirectional: + + const onVisibility = () => { + if (store.pollIntervalMs === undefined) return; + if (documentIsHidden()) { + if (anyOptOut(store)) return; // opt-out polls keep their timer + suspendPollTimer(store); // interval gone: zero wakeups + return; + } + if (store.pollSuspended) { + store.pollSuspended = false; + recomputePoll(store); // re-arms: pollIntervalMs intact + } + const entry = pickFetcherEntry(store); // existing make-up fetch (+ entry.deadlineMs, WP1) + if (entry) void runFetch(store, entry.fetcher, { replaceInflight: false, owner: entry.owner, deadlineMs: entry.deadlineMs }); + }; + +`anyOptOut(store)`: true when any polling listener has +`pauseWhenHiddenByListener.get(listener) === false` (the update-job restart poll, +use-dashboard-data.ts:440, keeps its 1.5s cadence while hidden — its whole purpose +is noticing the restarted server; it self-terminates with the job). + +`recomputePoll` two guards (audit #4/#8): +- While `store.pollSuspended`: recompute bookkeeping (`pollIntervalMs` may change + with churn) but NEVER arm a timer. +- At arm time: if `documentIsHidden() && !anyOptOut(store)`, set + `pollSuspended` instead of arming — a store first subscribed while the tab is + already hidden must not create a live timer (audit #8). + +Two teardown rules (round 2): +- `recomputePoll`'s `pollMs === undefined` branch AND `scheduleStoreEviction` both + reset `pollSuspended = false`. Otherwise a store whose last poller leaves while + hidden keeps the flag with the visibility listener gone, and the next polling + subscriber hits the never-arm guard with nothing left to resume it — the store + would never poll again. +- `ensureVisibilityListener` is installed whenever a polling subscriber exists, + REGARDLESS of whether a timer was armed — a mount-while-hidden store has no timer + but MUST still hold the listener, or its resume/make-up path never fires. + +### 2. NEW gui/src/visibility-poll.ts + + export type VisibilityPollOptions = { + /** Default true: hidden tabs neither tick nor hold a timer. */ + pauseWhenHidden?: boolean; + /** Fire once on start. Default false (existing pollers keep their first-run code). */ + immediate?: boolean; + }; + /** Returns stop(). Interval exists only while visible (unless opted out); + * visible-again fires one make-up tick immediately, then resumes the cadence. */ + export function startVisibilityPoll(callback: () => void, intervalMs: number, options?: VisibilityPollOptions): () => void; + +Implementation: one `visibilitychange` listener per active poll; hidden → +clearInterval; visible → callback() + setInterval. SSR/test-safe +(`typeof document === "undefined"` → plain interval). Callback errors remain the +caller's concern (all migrated callbacks catch or are made to). + +### 3. Raw-poller migrations (mechanical, per site) + +Pattern per file: delete `setInterval`/`clearInterval` pairs; the effect returns +`startVisibilityPoll(tick, MS)`. Each tick gains, where missing: +- an in-flight guard (ProviderSettings, Debug: skip while the previous tick pends — + Debug's unguarded 1s poll is explorer finding S6), +- the AbortSignal threaded into fetch (all sites marked NO in 002), +- `createBoundedFetch` for the 2s/1s hot pollers (ProviderSettings 10s bound, + Debug 10s bound). + +MemoryObservabilityCard: the 5s memory poll pauses when hidden (nobody reads the +paint); the 1.5s reconnect loop is LEFT running while hidden — it exists to flip the +restart banner the moment the server answers, it is bounded (5s fetch, 120s +give-up), and it only runs while a restart is actually in progress. Decision +recorded; no code change beyond a comment. + +## Behavior after + +| Scenario | Before | After | +|---|---|---| +| Tab hidden 10 min on Dashboard | 9 store timers wake ~1.5/s skipping ticks + raw pollers fire for real | zero timers, zero requests | +| Tab visible again | next tick whenever it lands | immediate make-up fetch per resource, then cadence | +| Restart update job while hidden | 1.5s poll keeps running | unchanged (opt-out) | +| Debug tab hidden | 1s + 2s fetches continue, hung ones stack | zero; guarded + bounded on return | +| Mount while already hidden | timer arms, ticks skipped | no timer; make-up on visible | + +## Tests + +visibility-poll.test.ts (happy-dom, setVisibility pattern from +client-resource-poll.test.tsx:64-74): +1. ticks on cadence while visible; 2. hidden → zero calls across 5 intervals; +3. visible → exactly one immediate make-up call then cadence resumes; +4. pauseWhenHidden:false keeps ticking while hidden; 5. stop() removes the listener + and timer while hidden (no zombie wakeup). +client-resource-poll.test.tsx additions: +6. polled store: hidden → fetch count frozen (not just skipped-tick), visible → + one make-up fetch + cadence resumes (extends the existing pauseWhenHidden tests); +7. opt-out subscriber present → store keeps polling while hidden (mixed subscriber + case, per-listener semantics preserved); +8. subscriber churn while hidden (unmount one of two subscribers) must NOT re-arm + a timer (audit #4); +9. first subscribe while already hidden arms no timer and still fires the make-up + fetch on visible (audit #8). +10. last polling subscriber leaves WHILE hidden → a later polling subscriber must + resume correctly: make-up fetch on visible, timer armed (round-2 blocker — + pollSuspended teardown-clear). + +Activation scenarios: tests 2/3/6/8/9 trigger the suspend/resume/guard branches +for real; browser check = hidden-tab request count (see below). + +## Verifiers + +- `cd gui && bun test tests/visibility-poll.test.ts tests/client-resource-poll.test.tsx tests/client-resource-deadline.test.tsx tests/logs-auto-refresh.test.tsx tests/debug-cache-revisit.test.tsx tests/add-codex-account-oauth.test.tsx tests/models-workspace-panels.test.tsx` + (final list re-derived at B via `rg -l "" tests/` for every + migrated file — audit task-6 nit) +- `cd gui && bun run build` +- Browser: in-app browser cannot background tabs (001/E4) → measurement is a + happy-dom integration count + a manual real-Chrome spot check documented in D. + +## Out of scope (WP3) + +Request-count reduction on visible tabs (WP4), re-activation staleness (WP4), +server push (SSE) migration. diff --git a/devlog/_plan/260816_gui_loading_performance/040_phase4_poll_consolidation.md b/devlog/_plan/260816_gui_loading_performance/040_phase4_poll_consolidation.md new file mode 100644 index 0000000000..47c700e34b --- /dev/null +++ b/devlog/_plan/260816_gui_loading_performance/040_phase4_poll_consolidation.md @@ -0,0 +1,162 @@ +# 040 — WP4: poll consolidation + re-activation revalidation + measured tuning + +Base: `codex/gui-hidden-pause` (WP3). Branch: `codex/gui-poll-consolidation`. +(Amended after audit round 1 — blockers #3/#11 folded in. Key design change: +cross-route freshness rides the session-cache seed path with a persisted +timestamp, NOT store survival — scheduleStoreEviction drops stores on route +change, so a store-level lastSettledAt alone could never fire on revisit.) +(Round 2: new-cache-wiring pages, module-level visibility listener, Startup +envelope migration, bucket lifecycle invariants folded in.) + +Revert note (audit extra): this is the stack's top layer; rollback = drop this PR. +Nothing below it depends on it. + +## Goal + +Visible-tab overhead drops measurably against the 001/E0 baseline (48 req / 31s on +Dashboard; 9 concurrent 5s store timers), and returning to a previously visited tab +shows acceptably fresh data without manual refresh and without a skeleton flash +(S7). Every tuning decision is justified by a before/after measurement recorded in +this doc's D-phase addendum. + +## File change map + +- MODIFY `gui/src/client-resource.ts` — (a) module-level tick scheduler replacing + per-store setInterval; (b) seed-freshness: `staleAfterMs` + `initialDataCachedAt` + options, `lastSettledAt` on the store for in-page re-subscribes. +- MODIFY `gui/src/data-surface.ts` — `DataSurfaceOptions.staleAfterMs` / + `initialDataCachedAt` pass-throughs (audit #3a: every target call site uses + useDataSurface; without this the option literals fail typecheck). +- MODIFY `gui/src/session-list-cache.ts` — timestamped entry helpers (below). +- MODIFY seeding call sites (integrate entry helpers + staleAfterMs: 60_000): + IntegrationsOverview.tsx:215-259, FileIntegrationPage.tsx:73/79, Combos.tsx:197, + ApiKeys.tsx:175/181, ClaudeCode.tsx:83, ClaudeDesktop.tsx:211, Grok.tsx:93, + Startup.tsx:206. NOTE (round 2): IntegrationsOverview and FileIntegrationPage have + NO session-cache wiring today (verified by rg) — they GAIN write-on-success in + their fetch callbacks + initialData/initialDataCachedAt seeding; this is new + wiring, not migration. Startup's own envelope (StartupPageCache {data, warning, + fix, tray}) carries no timestamp → migrate it to the entry envelope (siblings + ride inside data). Product consequence recorded: Startup health already answers + from a ~30s server-side cache (use-dashboard-data.ts:206-211), so + staleAfterMs: 60_000 stacks to up to ~90s stale on revisit — accepted; revisit in + the D addendum with the measurement. +- MODIFY `gui/src/pages/Logs.tsx` (:490) and `gui/src/pages/Debug.tsx` — interval + tuning ONLY if the WP4 measurement shows they dominate; decision recorded in D. +- NEW `gui/tests/client-resource-scheduler.test.tsx`, + NEW `gui/tests/client-resource-revalidate.test.tsx`, + MODIFY `gui/tests/client-resource-poll.test.tsx` (cascade from per-store timer + fields moving to buckets — expected stacked-PR churn, DEV-STACK-02). + +## Change spec + +### 1. Shared tick scheduler (client-resource.ts) + +Before: one `setInterval` per polling store (9 live timers on Dashboard alone). +After: module-level scheduler — + + type Bucket = { timer: Timeout | null; stores: Set>; suspended: boolean }; + const pollBuckets = new Map(); // key = interval ms + +`recomputePoll` registers/unregisters the store into its interval bucket instead of +owning a timer. The bucket's single timer iterates its stores and runs each store's +`pickPollEntry` per tick (per-store skip rules unchanged: in-flight skip, hidden +opt-out filter). WP3's suspend/resume moves to bucket granularity with the SAME two +guards (suspended buckets never re-arm on churn; a bucket created while hidden arms +nothing unless an opt-out store joins). Store-level `pollTimer`/`pollSuspended` +fields are removed; `scheduleStoreEviction` and `clearClientResourceStoresForTests` +updated to unregister from buckets. + +Invariants kept: smallest subscriber interval wins per store; a store changes +buckets when its effective interval changes; per-listener pauseWhenHidden semantics +are evaluated per store inside the shared tick. + +Bucket lifecycle (round 2): ONE module-level visibility listener iterates +`pollBuckets` (per-store listeners are removed with the per-store timers) — a +bucket created while hidden arms no timer but is still resumed by that listener. +Register/unregister/interval-change while a bucket is suspended update membership +only, never arm. An empty bucket is DELETED from pollBuckets (not merely +timer-cleared) — no slow leak of empty buckets; `clearClientResourceStoresForTests` +clears the map. + +### 2. Re-activation freshness via the SEED path (audit #3 rewrite) + +Route change unmounts the page → every store evicts (scheduleStoreEviction) → +revisit re-seeds from sessionStorage via `initialData`. So the freshness decision +belongs at seed time: + +a) session-list-cache.ts gains: + + export type SessionListEntry = { data: T; cachedAt: number | null }; + export function readSessionListCacheEntry(key: string): SessionListEntry | null; + export function writeSessionListCacheEntry(key: string, data: T): void; + +Entry writes store `{ __ocxCachedAt: number, data }`; the entry reader accepts BOTH +the envelope and legacy raw values (cachedAt: null = unknown age = treat as stale → +quiet revalidate → self-healing migration, no broken caches). + +b) ClientResourceOptions/DataSurfaceOptions gain: + + staleAfterMs?: number; // opt-in; absent = today's always-revalidate seed semantics + initialDataCachedAt?: number | null; // age evidence for the seed + +c) Seed path (seedClientResourceIfEmpty / setClientResourceData's seed branch): +when a seed lands with `initialDataCachedAt` and the (first) subscriber's +`staleAfterMs`: age < staleAfterMs → seed WITHOUT `seedNeedsRevalidate` (no +fetch on revisit at all); age ≥ → seed WITH `seedNeedsRevalidate` (existing quiet +revalidate on mount: seeded data stays visible, `refreshing` only — no skeleton +flash either way). First-subscriber-wins for staleAfterMs on shared keys (audit +#11; all opted-in sites here use disjoint keys). + +d) Store `lastSettledAt` is still tracked (success settle + setClientResourceData) +and consulted in subscribeResource's 0→1 transition for stores that SURVIVED +(in-page enabled-gate re-subscribes): same staleAfterMs comparison, quiet +revalidate when stale. + +### 3. Measured interval tuning (decision at this cycle's P re-verification) + +Measure first (CDP, same protocol as 001/E0) with WP1-WP3 landed. Only if Logs +auto-refresh (2s, limit=2000) or Debug (1s+2s) dominate the visible-tab rate do +their defaults change; any change is a one-line constant + test update, recorded +with the measurement. No freshness-affecting change without the numbers. + +## Tests + +client-resource-scheduler.test.tsx: +1. N stores sharing an interval fire on one timer (advance happy-dom timers once, + all N fetchers ran). +2. store interval change moves it between buckets; an empty bucket is removed from + pollBuckets (bucket-count probe, not just timer-clear). +3. hidden suspends bucket timers; visible re-arms + per-store make-up (WP3 parity); + churn while suspended arms nothing (WP3 audit #4 guard, bucket port). +4. per-store in-flight skip still applies inside a shared tick. +4b. mount-while-hidden in bucket form (WP3 test 9 ported): bucket created while + hidden arms no timer; module-level listener still resumes it on visible with a + make-up fetch. +client-resource-revalidate.test.tsx: +5. seed younger than staleAfterMs → NO fetch on subscribe (fresh seed). +6. seed older / legacy untimestamped cache → exactly one quiet revalidate; seeded + data visible throughout (refreshing true, showSkeleton false via + classifyDataSurface). +7. no staleAfterMs → today's always-revalidate seed behavior (regression guard). +8. surviving store re-subscribe younger than staleAfterMs → no refetch; older → + one quiet refetch (lastSettledAt path). +session-list-cache entry tests (extend the existing cache test file if present, +else add cases to client-resource-revalidate.test.tsx): +9. entry write→read round-trip carries cachedAt; legacy raw value reads as + cachedAt:null; round-trip the NEW IntegrationsOverview/FileIntegrationPage + payload shapes and the migrated Startup envelope. + +## Verifiers + +- Focused new tests + client-resource suites + `cd gui && bun run build` +- Measurement: repeat 001/E0 (31s Dashboard dwell, CDP count) → before/after table + in the D addendum. +- Browser revisit flow (matches the seed mechanism): open Integrations (seeds + cache), switch to Dashboard, wait >60s, return → observe exactly one + /api/client-integrations* request with the seeded content visible the whole time + (no skeleton); repeat within 60s → zero requests. + +## Out of scope (WP4) + +Server-side endpoint merging, render-level memoization audits, virtualization +tuning, bundle-size work. diff --git a/gui/src/client-resource.ts b/gui/src/client-resource.ts index 03b6d1d8f4..6c9d22efb4 100644 --- a/gui/src/client-resource.ts +++ b/gui/src/client-resource.ts @@ -30,6 +30,8 @@ type Store = { pauseWhenHiddenByListener: Map<() => void, boolean>; /** listener → fetcher owned by that subscriber */ fetcherByListener: Map<() => void, (signal: AbortSignal) => Promise>; + /** listener → per-attempt deadline owned by that subscriber (undefined = default) */ + deadlineByListener: Map<() => void, number | undefined>; subscriberCount: number; pollTimer: ReturnType | null; /** Currently scheduled poll interval; avoids resetting the countdown on churn. */ @@ -54,6 +56,15 @@ type Store = { */ const stores = new Map>(); +/** + * Per-attempt deadline for every store fetch. Endpoints documented as slow finish in + * ~5s; 30s leaves generous headroom. Without this a hung request wedges the store + * forever: poll ticks skip while inflight and no abort ever fires on its own. + */ +const DEFAULT_REQUEST_DEADLINE_MS = 30_000; +/** Abort-reason sentinel distinguishing the deadline from owner aborts (replace/unmount). */ +const RESOURCE_TIMEOUT = "ocx-resource-deadline"; + const EMPTY_SNAPSHOT: ResourceSnapshot = { data: undefined, error: undefined, @@ -79,6 +90,7 @@ function getStore(key: string): Store { pollByListener: new Map(), pauseWhenHiddenByListener: new Map(), fetcherByListener: new Map(), + deadlineByListener: new Map(), subscriberCount: 0, pollTimer: null, pollIntervalMs: undefined, @@ -119,13 +131,13 @@ function documentIsHidden(): boolean { */ function pickPollEntry( store: Store, -): { owner: () => void; fetcher: (signal: AbortSignal) => Promise } | null { +): { owner: () => void; fetcher: (signal: AbortSignal) => Promise; deadlineMs: number | undefined } | null { if (!documentIsHidden()) return pickFetcherEntry(store); for (const [listener, ms] of store.pollByListener) { if (typeof ms !== "number" || ms <= 0) continue; if (store.pauseWhenHiddenByListener.get(listener) === false) { const fetcher = store.fetcherByListener.get(listener); - if (fetcher) return { owner: listener, fetcher }; + if (fetcher) return { owner: listener, fetcher, deadlineMs: store.deadlineByListener.get(listener) }; } } return null; @@ -134,15 +146,15 @@ function pickPollEntry( /** Prefer a polling subscriber's fetcher; otherwise any remaining subscriber. */ function pickFetcherEntry( store: Store, -): { owner: () => void; fetcher: (signal: AbortSignal) => Promise } | null { +): { owner: () => void; fetcher: (signal: AbortSignal) => Promise; deadlineMs: number | undefined } | null { for (const [listener, ms] of store.pollByListener) { if (typeof ms === "number" && ms > 0) { const fetcher = store.fetcherByListener.get(listener); - if (fetcher) return { owner: listener, fetcher }; + if (fetcher) return { owner: listener, fetcher, deadlineMs: store.deadlineByListener.get(listener) }; } } for (const [listener, fetcher] of store.fetcherByListener) { - return { owner: listener, fetcher }; + return { owner: listener, fetcher, deadlineMs: store.deadlineByListener.get(listener) }; } return null; } @@ -170,7 +182,7 @@ function recomputePoll(store: Store) { const entry = pickPollEntry(store); if (!entry) return; // Skip ticks while a request is in flight so slow polls can finish. - void runFetch(store, entry.fetcher, { replaceInflight: false, owner: entry.owner }); + void runFetch(store, entry.fetcher, { replaceInflight: false, owner: entry.owner, deadlineMs: entry.deadlineMs }); }, pollMs); ensureVisibilityListener(store); } @@ -189,7 +201,7 @@ function ensureVisibilityListener(store: Store) { if (store.pollIntervalMs === undefined) return; const entry = pickFetcherEntry(store); if (!entry) return; - void runFetch(store, entry.fetcher, { replaceInflight: false, owner: entry.owner }); + void runFetch(store, entry.fetcher, { replaceInflight: false, owner: entry.owner, deadlineMs: entry.deadlineMs }); }; document.addEventListener("visibilitychange", onVisible); store.visibilityListener = onVisible; @@ -206,7 +218,7 @@ function removeVisibilityListener(store: Store) { async function runFetch( store: Store, fetcher: (signal: AbortSignal) => Promise, - options?: { replaceInflight?: boolean; owner?: (() => void) | null; forceLoading?: boolean }, + options?: { replaceInflight?: boolean; owner?: (() => void) | null; forceLoading?: boolean; deadlineMs?: number }, ) { const replaceInflight = options?.replaceInflight !== false; if (store.inflight && !replaceInflight) return; @@ -217,6 +229,21 @@ async function runFetch( store.inflightOwner = options?.owner ?? null; const gen = ++store.generation; + /** + * Every attempt has a deadline, enforced two ways at once: + * - the abort tells well-behaved fetchers to stop (with RESOURCE_TIMEOUT as the + * reason, so the settle guard can tell the deadline apart from an owner abort); + * - the race settles the store even when a fetcher drops the signal entirely. + * Without the flag, a fallback that aborts the guarded controller would make a + * timeout indistinguishable from an owner abort and the store would never settle. + */ + const deadlineMs = options?.deadlineMs ?? DEFAULT_REQUEST_DEADLINE_MS; + let timedOut = false; + const deadlineTimer = setTimeout(() => { + timedOut = true; + controller.abort(RESOURCE_TIMEOUT); + }, deadlineMs); + // Falsy cached values stay visible during polls; forceLoading is for identity changes (deps). // `refreshing` always rises so a slow revalidation is observable without blanking content. const shouldShowLoading = store.snapshot.data === undefined || options?.forceLoading === true; @@ -228,7 +255,17 @@ async function runFetch( emit(store); try { - const data = await fetcher(controller.signal); + const data = await Promise.race([ + fetcher(controller.signal), + new Promise((resolve, reject) => { + controller.signal.addEventListener("abort", () => { + if (timedOut) reject(new Error(`resource request timed out after ${deadlineMs}ms`)); + // An owner abort frees the race frame immediately; the success guard below + // early-returns on signal.aborted, so this never settles data. + else resolve(null as never); + }, { once: true }); + }), + ]); if (gen !== store.generation || controller.signal.aborted) return; // Cleared on settle (not at subscribe) so StrictMode's aborted first mount still revalidates. store.seedNeedsRevalidate = false; @@ -241,7 +278,10 @@ async function runFetch( lastAttemptOk: true, }; } catch (error) { - if (gen !== store.generation || controller.signal.aborted) return; + if (gen !== store.generation) return; + // Owner aborts (replace/unmount) never settle; only the deadline and real + // fetcher rejections reach the failure path. + if (controller.signal.aborted && !timedOut) return; store.seedNeedsRevalidate = false; store.snapshot = { ...store.snapshot, @@ -252,6 +292,7 @@ async function runFetch( lastAttemptOk: false, }; } finally { + clearTimeout(deadlineTimer); if (store.inflight === controller) { store.inflight = null; store.inflightOwner = null; @@ -294,25 +335,33 @@ function scheduleStoreEviction(key: string, store: Store) { }, 0); } +/** Per-listener registration: one object instead of positional sprawl. */ +type ListenerRegistration = { + fetcher: (signal: AbortSignal) => Promise; + pollMs?: number; + pauseWhenHidden?: boolean; + deadlineMs?: number; +}; + function subscribeResource( key: string, - fetcher: (signal: AbortSignal) => Promise, - pollMs: number | undefined, onStoreChange: () => void, - pauseWhenHidden = true, + registration: ListenerRegistration, ) { + const { fetcher, pollMs, pauseWhenHidden = true, deadlineMs } = registration; const store = getStore(key); store.listeners.add(onStoreChange); store.pollByListener.set(onStoreChange, pollMs); store.pauseWhenHiddenByListener.set(onStoreChange, pauseWhenHidden); store.fetcherByListener.set(onStoreChange, fetcher); + store.deadlineByListener.set(onStoreChange, deadlineMs); store.subscriberCount++; // Cold start, or a pre-subscribe seed that still needs a network check. Keep // cached data across transient 0→1 resubscribe gaps when neither applies. if (store.subscriberCount === 1) { if (store.snapshot.data === undefined || store.seedNeedsRevalidate) { - void runFetch(store, fetcher, { replaceInflight: true, owner: onStoreChange }); + void runFetch(store, fetcher, { replaceInflight: true, owner: onStoreChange, deadlineMs }); } } recomputePoll(store); @@ -322,6 +371,7 @@ function subscribeResource( store.pollByListener.delete(onStoreChange); store.pauseWhenHiddenByListener.delete(onStoreChange); store.fetcherByListener.delete(onStoreChange); + store.deadlineByListener.delete(onStoreChange); store.subscriberCount--; // Drop this subscriber's in-flight work so a late resolve cannot stomp shared data. const abortedOwned = abortInflightOwnedBy(store, onStoreChange); @@ -333,7 +383,7 @@ function subscribeResource( if (abortedOwned) { const entry = pickFetcherEntry(store); if (entry) { - void runFetch(store, entry.fetcher, { replaceInflight: true, owner: entry.owner }); + void runFetch(store, entry.fetcher, { replaceInflight: true, owner: entry.owner, deadlineMs: entry.deadlineMs }); } } recomputePoll(store); @@ -356,6 +406,12 @@ export interface ClientResourceOptions { * stay quiet while the mount fetch still quiet-revalidates via `seedNeedsRevalidate`. */ initialData?: T; + /** + * Per-attempt deadline override. Default DEFAULT_REQUEST_DEADLINE_MS (30s). Raise it + * for endpoints documented as slow; a timed-out attempt settles failed instead of + * wedging the store. + */ + deadlineMs?: number; } /** Seed an empty, unsubscribed store. No-ops when data already exists or someone is listening. */ @@ -375,6 +431,7 @@ export function useClientResource( // Default true: a background tab has nobody reading the paint. Opt out for polls that // must keep running while hidden, such as waiting for a restarted server to answer. const pauseWhenHidden = options?.pauseWhenHidden !== false; + const deadlineMs = options?.deadlineMs; if (enabled && options?.initialData !== undefined) { seedClientResourceIfEmpty(key, options.initialData); } @@ -396,9 +453,9 @@ export function useClientResource( (onStoreChange: () => void) => { if (!enabled) return () => {}; listenerRef.current = onStoreChange; - return subscribeResource(key, stableFetcher, pollMs, onStoreChange, pauseWhenHidden); + return subscribeResource(key, onStoreChange, { fetcher: stableFetcher, pollMs, pauseWhenHidden, deadlineMs }); }, - [key, stableFetcher, pollMs, enabled, pauseWhenHidden], + [key, stableFetcher, pollMs, enabled, pauseWhenHidden, deadlineMs], ); const getSnapshot = useCallback((): ResourceSnapshot => { @@ -410,12 +467,16 @@ export function useClientResource( const refresh = useCallback((opts?: { forceLoading?: boolean }) => { if (!enabled) return; - void runFetch(getStore(key), stableFetcher, { + const store = getStore(key); + void runFetch(store, stableFetcher, { replaceInflight: true, owner: listenerRef.current, forceLoading: opts?.forceLoading, + // The refreshing listener's own deadline — not a frozen option — so a shared + // key keeps each owner's contract. + deadlineMs: listenerRef.current ? store.deadlineByListener.get(listenerRef.current) : deadlineMs, }); - }, [key, stableFetcher, enabled]); + }, [key, stableFetcher, enabled, deadlineMs]); return { ...snapshot, refresh }; } diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index c492263931..ae080fb8a6 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -87,6 +87,7 @@ export default function AddProviderModal({ if (!res.ok) throw new Error(String(res.status)); return await res.json() as { providers?: Array<{ provider: string; requests: number }> }; }, + { deadlineMs: 60_000 }, // shared usage-summary key: all four subscribers raise the deadline together ); const oauthSupported = oauthPoll.data ?? []; diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx index 85bd309494..5ef62e13fc 100644 --- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx @@ -175,7 +175,8 @@ export default function ProviderWorkspaceShell({ }); const [modelsLoadEpoch, setModelsLoadEpoch] = useState(0); const filterWrapRef = useRef(null); - const usageResource = useKeyedClientResource(usageSummary30dResourceKey(apiBase), [apiBase], async (signal) => { const res = await fetch(apiBase + "/api/usage?range=30d", { signal }); if (!res.ok) throw new Error(String(res.status)); return await res.json(); }); + // Shared usage-summary key: all four subscribers raise the deadline together (30d usage is ~5s cold). + const usageResource = useKeyedClientResource(usageSummary30dResourceKey(apiBase), [apiBase], async (signal) => { const res = await fetch(apiBase + "/api/usage?range=30d", { signal }); if (!res.ok) throw new Error(String(res.status)); return await res.json(); }, { deadlineMs: 60_000 }); const sections = useMemo(() => { const base = buildProviderWorkspace(hideRedundantChatGptForwardProviders(providers)); diff --git a/gui/src/data-surface.ts b/gui/src/data-surface.ts index 917460e685..f587e56596 100644 --- a/gui/src/data-surface.ts +++ b/gui/src/data-surface.ts @@ -45,6 +45,8 @@ export type DataSurfaceOptions = { pauseWhenHidden?: boolean; /** Forwarded to the resource layer; see ClientResourceOptions.initialData. */ initialData?: T; + /** Forwarded to the resource layer; see ClientResourceOptions.deadlineMs. */ + deadlineMs?: number; }; /** diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 380fb224dd..4db00f7cac 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -376,7 +376,9 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; }, // Gated on the catalog tab: a 10-second poll that keeps running while the user // reads Combos or Routing is exactly the hidden work this workspace avoids. - { isEmpty: () => false, pollMs: 10_000, initialData: cached ?? undefined, enabled: catalogActive }, + // Live model discovery is slow; the catalog gets a raised deadline so a slow + // response is never misread as a hung one. + { isEmpty: () => false, pollMs: 10_000, initialData: cached ?? undefined, enabled: catalogActive, deadlineMs: 60_000 }, ); const catalogState = catalogResource.state; diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 6c23f43e70..3c0c6c10b8 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -118,6 +118,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { if (!res.ok) throw new Error(String(res.status)); return await res.json() as { providers?: Array<{ provider: string; requests: number }> }; }, + { deadlineMs: 60_000 }, // shared usage-summary key: all four subscribers raise the deadline together ); /* * Quota revalidation is driven by an explicit revision, not by anything derived from diff --git a/gui/src/pages/Subagents.tsx b/gui/src/pages/Subagents.tsx index cc1028fc76..ea36769466 100644 --- a/gui/src/pages/Subagents.tsx +++ b/gui/src/pages/Subagents.tsx @@ -113,8 +113,10 @@ export default function Subagents({ apiBase }: { apiBase: string }) { } }, [loadUltraMode, t]); - const loadSubagents = useCallback(async (): Promise => { - const res = await fetch(`${apiBase}/api/subagent-models`); + const loadSubagents = useCallback(async (signal?: AbortSignal): Promise => { + // The resource layer's deadline abort must reach the wire — a signal dropped + // here is a store that can only settle by race timeout. + const res = await fetch(`${apiBase}/api/subagent-models`, { signal }); const response = await readJsonOrThrow<{ available?: string[]; chosen?: string[] }>(res, t("sub.loadFail")); if (!response) throw new Error(t("sub.loadFail")); const available = response.available ?? []; diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index 54b2ef6803..27e23c162d 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -268,7 +268,9 @@ export function useDashboardData(apiBase: string) { usageSummary30dResourceKey(apiBase), [apiBase], (signal) => fetchDashboardUsage(apiBase, signal), - { enabled: overviewReady }, + // 30d usage is documented ~5s cold; this shared key has four subscribers, so + // every one of them carries the same raised deadline (mount-order independent). + { enabled: overviewReady, deadlineMs: 60_000 }, ); const diagnosticsPoll = useKeyedClientResource( diff --git a/gui/tests/client-resource-deadline.test.tsx b/gui/tests/client-resource-deadline.test.tsx new file mode 100644 index 0000000000..d6a0c95084 --- /dev/null +++ b/gui/tests/client-resource-deadline.test.tsx @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, expect, test as bunTest } from "bun:test"; +import { Window } from "happy-dom"; +import { act, useState } from "react"; +import type { Root } from "react-dom/client"; +import { + clearClientResourceStoresForTests, + useClientResource, +} from "../src/client-resource"; +import { classifyDataSurface } from "../src/data-surface"; + +// Same harness as client-resource-poll.test.tsx: busy CI runners need the ceiling. +function test(name: string, fn: () => void | Promise): void { + bunTest(name, fn, { timeout: 30_000 }); +} + +const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +const mountedRoots: Root[] = []; + +beforeEach(() => { + clearClientResourceStoresForTests(); + 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 }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + // Unmount before globals restore: a settle microtask firing after restore would + // emit into React with window undefined. + for (const root of mountedRoots.splice(0)) { + act(() => { + root.unmount(); + }); + } + clearClientResourceStoresForTests(); + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function waitFor(predicate: () => boolean, timeoutMs = 15_000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out"); + await act(async () => { + await new Promise((resolve) => testWindow.setTimeout(resolve, 10)); + }); + } +} + +type SnapshotProbe = { current: ReturnType> | null }; + +async function mountResource(opts: { + key: string; + fetcher: (signal: AbortSignal) => Promise; + pollMs?: number; + deadlineMs?: number; +}): Promise<{ probe: SnapshotProbe; root: Root; container: HTMLElement }> { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + const probe: SnapshotProbe = { current: null }; + function Subscriber() { + probe.current = useClientResource(opts.key, opts.fetcher, { pollMs: opts.pollMs, deadlineMs: opts.deadlineMs }); + return ; + } + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + mountedRoots.push(root); + return { probe, root, container }; +} +test("never-settling fetcher settles failed within the deadline", async () => { + const { probe } = await mountResource({ + key: `deadline-settle-${Date.now()}`, + fetcher: () => new Promise(() => {}), + deadlineMs: 50, + }); + await waitFor(() => probe.current?.lastAttemptOk === false && probe.current?.error instanceof Error); + expect(probe.current?.loading).toBe(false); + expect(probe.current?.refreshing).toBe(false); + expect(String((probe.current?.error as Error).message)).toContain("timed out"); +}); + +test("polled store self-heals after a timed-out attempt", async () => { + let attempts = 0; + const { probe } = await mountResource({ + key: `deadline-heal-${Date.now()}`, + fetcher: () => { + attempts++; + return attempts === 1 ? new Promise(() => {}) : Promise.resolve("ok"); + }, + pollMs: 40, + deadlineMs: 50, + }); + await waitFor(() => probe.current?.data === "ok"); + expect(attempts).toBeGreaterThanOrEqual(2); +}); + +test("timed-out cold store shows the error surface, not a skeleton", async () => { + const { probe } = await mountResource({ + key: `deadline-surface-${Date.now()}`, + fetcher: () => new Promise(() => {}), + deadlineMs: 50, + }); + // lastAttemptOk starts false (EMPTY_SNAPSHOT) — only an error marks a real settle. + await waitFor(() => probe.current?.error instanceof Error); + const state = classifyDataSurface(probe.current!, () => false, true); + expect(state.kind).toBe("failed-cold"); + expect(state.showSkeleton).toBe(false); + expect(state.showError).toBe(true); +}); + +test("unmount during the deadline window still takes the abort path", async () => { + const { probe, root } = await mountResource({ + key: `deadline-unmount-${Date.now()}`, + fetcher: () => new Promise(() => {}), + deadlineMs: 10_000, + }); + await waitFor(() => probe.current?.refreshing === true); + await act(async () => { + root.unmount(); + }); + // The aborted attempt must never surface as a failure settle for a remount. + const { probe: probe2 } = await mountResource({ + key: `deadline-unmount-2-${Date.now()}`, + fetcher: () => Promise.resolve("fresh"), + deadlineMs: 10_000, + }); + await waitFor(() => probe2.current?.data === "fresh"); + expect(probe2.current?.lastAttemptOk).toBe(true); +}); + +test("manual refresh after timeout recovers immediately", async () => { + let settle = false; + const { probe } = await mountResource({ + key: `deadline-refresh-${Date.now()}`, + fetcher: () => (settle ? Promise.resolve("recovered") : new Promise(() => {})), + deadlineMs: 50, + }); + await waitFor(() => probe.current?.lastAttemptOk === false); + settle = true; + await act(async () => { + probe.current?.refresh(); + }); + await waitFor(() => probe.current?.data === "recovered"); +}); + +test("signal-dropping fetcher is bounded by the race", async () => { + const { probe } = await mountResource({ + key: `deadline-signaldrop-${Date.now()}`, + // Ignores the abort signal entirely: only the race can settle the store. + fetcher: () => new Promise(() => {}), + deadlineMs: 50, + }); + await waitFor(() => probe.current?.lastAttemptOk === false && probe.current?.refreshing === false); +}); + +test("owner abort after the deadline fired settles exactly once, as the timeout", async () => { + let replace: (() => void) | null = null; + let attempts = 0; + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + const probe: SnapshotProbe = { current: null }; + function Harness() { + const [gen, setGen] = useState(0); + replace = () => setGen(1); + probe.current = useClientResource( + `deadline-race-${gen}`, + () => { + attempts++; + return new Promise(() => {}); + }, + { deadlineMs: 50 }, + ); + return ; + } + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + mountedRoots.push(root); + // Let the first key's deadline fire and settle (error — lastAttemptOk starts false)... + await waitFor(() => probe.current?.error instanceof Error); + const failedError = probe.current?.error; + // ...then replace it (new key = owner abort of nothing + fresh mount). + await act(async () => { + replace!(); + }); + await waitFor(() => attempts >= 2 && probe.current?.error instanceof Error); + expect(String((failedError as Error).message)).toContain("timed out"); + await act(async () => { + root.unmount(); + }); +});