-
Notifications
You must be signed in to change notification settings - Fork 808
perf(gui): hidden tab holds zero timers and fires zero requests #1856
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b894e93
7540ca8
4596aa0
e1beb5e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -169,3 +169,45 @@ for real; browser check = hidden-tab request count (see below). | |
|
|
||
| Request-count reduction on visible tabs (WP4), re-activation staleness (WP4), | ||
| server push (SSE) migration. | ||
|
|
||
| ## D addendum — landed (2026-08-16/17) | ||
|
|
||
| Implementation: commits 955d90b34 (suspension state machine, visibility-poll.ts, | ||
| nine poller migrations, 9 new tests) and 794466b10 (round-5 audit fixes). | ||
|
|
||
| Audit history: binding round r5 returned FAIL first — | ||
|
|
||
| - HIGH: the Debug 1s tail poll lost its `useEffectEvent` dispatch in the migration, | ||
| so a stream switch (provider→usage, both enabled) kept polling the OLD stream: | ||
| wrong entries appended into the new buffer, the shared `logGenerationRef` bumped | ||
| by stale ticks cancelling the new stream's initial load, and corrupted `afterRef` | ||
| seqs. Fixed by dispatching the tick through `useEffectEvent` again — every tick | ||
| reads the latest `fetchLogs` while the interval stays pinned to | ||
| `[active, follow, streamEnabled]`. | ||
| - MEDIUM: `recomputePoll` never re-evaluated `anyOptOut` in its keep-countdown and | ||
| suspended branches, so an opt-out subscriber leaving while hidden left a timer | ||
| waking with nothing eligible, and an opt-out joining a suspended store never ran | ||
| until visible. Both branches now re-check `documentIsHidden() && !anyOptOut`. | ||
|
|
||
| Round 2 PASS: the reviewer enumerated all four recomputePoll paths (teardown / | ||
| unchanged-interval / suspended / changed-interval) against the failure shapes — | ||
| no visible store ends timerless, no hidden non-opt-out store keeps a timer, the | ||
| listener is never lost while a poll is registered, and the suspended fall-through | ||
| cannot double-arm. Also folded in: abort signals for the three remaining 30s | ||
| pollers, `loadShadowCall` clearing in `finally`, and a throw guard in | ||
| `startVisibilityPoll` (without it a throwing make-up tick would skip `arm()` and | ||
| kill the cadence permanently). | ||
|
|
||
| Verification evidence: | ||
| - `cd gui && bun test` over the 22 touched-surface suites → 146 pass / 0 fail. | ||
| - `bun run lint` (oxlint) and `bun run build` (tsc -b + vite) green. | ||
|
Comment on lines
+202
to
+203
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Reconcile the verification scope. The addendum reports 146 passing tests across 22 touched-surface suites, while the PR objectives report 922 passing tests. It also omits the stated i18n lint result. Label 146 as a scoped subset and record the full-suite and i18n-lint results, or correct the stale verification record. 🤖 Prompt for AI Agents |
||
| - Hidden-tab proof is the happy-dom timer probe (`hasPollTimerForTests`), not live | ||
| emulation: as 001/E4 predicted, the in-app browser keeps background tabs | ||
| `visible`, `Emulation.setPageVisibilityState` is not exposed through the raw CDP | ||
| channel, and page-context `visibilityState` patching does not stick because | ||
| evaluation runs in an isolated world. Fetch-count assertions alone cannot see | ||
| this guarantee (a skipped tick looks identical to no tick), which is exactly why | ||
| the timer probe exists. | ||
| - Visible-tab baseline re-measured after the change (12s dwell on Dashboard, | ||
| sandboxed instance): unchanged cadence, no regression in request volume — the | ||
| reduction work is WP4's. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -36,6 +36,12 @@ type Store<T> = { | |
| pollTimer: ReturnType<typeof setInterval> | null; | ||
| /** Currently scheduled poll interval; avoids resetting the countdown on churn. */ | ||
| pollIntervalMs: number | undefined; | ||
| /** | ||
| * Hidden with no opt-out subscriber: the timer is GONE (not just skipped) and only | ||
| * a visibility transition back to visible re-arms it. pollIntervalMs survives | ||
| * suspension so churn cannot re-arm through the changed-interval path. | ||
| */ | ||
| pollSuspended: boolean; | ||
| inflight: AbortController | null; | ||
| /** Subscriber that started the current in-flight request (if any). */ | ||
| inflightOwner: (() => void) | null; | ||
|
|
@@ -94,6 +100,7 @@ function getStore<T>(key: string): Store<T> { | |
| subscriberCount: 0, | ||
| pollTimer: null, | ||
| pollIntervalMs: undefined, | ||
| pollSuspended: false, | ||
| inflight: null, | ||
| inflightOwner: null, | ||
| visibilityListener: null, | ||
|
|
@@ -117,6 +124,30 @@ function clearPollTimer<T>(store: Store<T>) { | |
| store.pollIntervalMs = undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Clear only the timer and mark the store suspended. Unlike clearPollTimer this | ||
| * KEEPS pollIntervalMs — clearing it would let hidden-phase subscriber churn | ||
| * (StrictMode, tab mounts) re-arm a fresh interval through recomputePoll's | ||
| * changed-interval path. | ||
| */ | ||
| function suspendPollTimer<T>(store: Store<T>) { | ||
| if (store.pollTimer !== null) { | ||
| clearInterval(store.pollTimer); | ||
| store.pollTimer = null; | ||
| } | ||
| store.pollSuspended = true; | ||
| } | ||
|
|
||
| /** True when any polling subscriber opted out of hidden pausing (e.g. restart watch). */ | ||
| function anyOptOut<T>(store: Store<T>): boolean { | ||
| for (const [listener, ms] of store.pollByListener) { | ||
| if (typeof ms === "number" && ms > 0 && store.pauseWhenHiddenByListener.get(listener) === false) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
|
Comment on lines
+141
to
+149
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
ast-grep outline gui/src/client-resource.ts --items all
rg -n -C 12 --type ts 'pickPollEntry\s*\(' gui/src/client-resource.tsRepository: lidge-jun/opencodex Length of output: 3092 🏁 Script executed: #!/bin/bash
set -euo pipefail
cat -n gui/src/client-resource.ts | sed -n '130,275p'
rg -n -C 15 --type ts --glob '*test*' 'pauseWhenHidden|opt.?out|hidden|pickPollEntry' gui testsRepository: lidge-jun/opencodex Length of output: 50376 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- client-resource implementation ---'
cat -n gui/src/client-resource.ts | sed -n '155,245p'
printf '%s\n' '--- relevant test files ---'
git ls-files | rg '(^|/)(client-resource|resource).*(test|spec)|(^|/)client-resource'
printf '%s\n' '--- relevant test references ---'
rg -n -C 8 --glob '*.{test,spec}.{ts,tsx}' 'pauseWhenHidden|pollByListener|hasPollTimerForTests|visibilitychange|document\.hidden' gui tests 2>/dev/null | head -n 400Repository: lidge-jun/opencodex Length of output: 24910 🏁 Script executed: #!/bin/bash
set -euo pipefail
cat -n gui/tests/client-resource-poll.test.tsx | sed -n '365,490p'Repository: lidge-jun/opencodex Length of output: 5591 Strengthen the shared-key hidden polling test.
🤖 Prompt for AI Agents |
||
|
|
||
| /** True when the document is currently hidden. Safe on non-browser runtimes. */ | ||
| function documentIsHidden(): boolean { | ||
| return typeof document !== "undefined" && document.visibilityState === "hidden"; | ||
|
|
@@ -167,15 +198,37 @@ function recomputePoll<T>(store: Store<T>) { | |
| pollMs = pollMs === undefined ? ms : Math.min(pollMs, ms); | ||
| } | ||
| } | ||
| if (pollMs === undefined) { | ||
| // No subscriber polls any more: full teardown. A suspended store resets here so | ||
| // the next polling subscriber starts clean even while the tab is still hidden — | ||
| // otherwise the flag would outlive the listener that resumes it. | ||
| clearPollTimer(store); | ||
| store.pollSuspended = false; | ||
| removeVisibilityListener(store); | ||
| return; | ||
| } | ||
| // Keep the existing countdown when the effective interval is unchanged. | ||
| if (pollMs === store.pollIntervalMs && (pollMs === undefined || store.pollTimer !== null)) { | ||
| if (pollMs === store.pollIntervalMs && store.pollTimer !== null) { | ||
| // Opt-out churn with an unchanged interval: the last opt-out left while hidden — | ||
| // the timer would keep waking every interval with zero eligible entries. | ||
| if (documentIsHidden() && !anyOptOut(store)) suspendPollTimer(store); | ||
| return; | ||
| } | ||
| if (store.pollSuspended) { | ||
| store.pollIntervalMs = pollMs; | ||
| // Hidden with no opt-out: bookkeeping only — arming is the visibility handler's | ||
| // job. An opt-out subscriber joining a suspended store arms below even while | ||
| // hidden: noticing the off-screen event is its documented purpose. | ||
| if (documentIsHidden() && !anyOptOut(store)) return; | ||
| store.pollSuspended = false; | ||
| } | ||
| clearPollTimer(store); | ||
| store.pollIntervalMs = pollMs; | ||
| if (pollMs === undefined) { | ||
| // No subscriber polls any more, so there is no skipped tick to make up on return. | ||
| removeVisibilityListener(store); | ||
| if (documentIsHidden() && !anyOptOut(store)) { | ||
| // First subscribed (or re-armed) while already hidden: hold no timer at all. | ||
| // The listener still installs so the resume + make-up path stays live. | ||
| store.pollSuspended = true; | ||
| ensureVisibilityListener(store); | ||
| return; | ||
| } | ||
| store.pollTimer = setInterval(() => { | ||
|
|
@@ -188,23 +241,34 @@ function recomputePoll<T>(store: Store<T>) { | |
| } | ||
|
|
||
| /** | ||
| * One listener per polling store: when the tab comes back, the skipped ticks are made up | ||
| * with a single quiet revalidation instead of waiting out the remaining interval. | ||
| * One listener per polling store, installed whenever a polling subscriber exists — | ||
| * regardless of whether a timer is currently armed (a mount-while-hidden store has | ||
| * no timer but MUST still resume). On hidden the timer is suspended outright (zero | ||
| * wakeups, unless an opt-out subscriber keeps it); on visible the skipped ticks are | ||
| * made up with a single quiet revalidation and the cadence re-arms. | ||
| * | ||
| * `replaceInflight: false` keeps this from cancelling work a visible-again mount just | ||
| * started; if something is already loading, that request is the fresh answer. | ||
| */ | ||
| function ensureVisibilityListener<T>(store: Store<T>) { | ||
| if (typeof document === "undefined" || store.visibilityListener) return; | ||
| const onVisible = () => { | ||
| if (documentIsHidden()) return; | ||
| const onVisibility = () => { | ||
| if (store.pollIntervalMs === undefined) return; | ||
| if (documentIsHidden()) { | ||
| if (anyOptOut(store)) return; // opt-out polls keep their timer running | ||
| suspendPollTimer(store); | ||
| return; | ||
| } | ||
| if (store.pollSuspended) { | ||
| store.pollSuspended = false; | ||
| recomputePoll(store); // re-arms: pollIntervalMs survived the suspension | ||
| } | ||
| const entry = pickFetcherEntry(store); | ||
| if (!entry) return; | ||
| void runFetch(store, entry.fetcher, { replaceInflight: false, owner: entry.owner, deadlineMs: entry.deadlineMs }); | ||
| }; | ||
| document.addEventListener("visibilitychange", onVisible); | ||
| store.visibilityListener = onVisible; | ||
| document.addEventListener("visibilitychange", onVisibility); | ||
| store.visibilityListener = onVisibility; | ||
| } | ||
|
|
||
| function removeVisibilityListener<T>(store: Store<T>) { | ||
|
|
@@ -322,6 +386,7 @@ function abortInflightOwnedBy<T>(store: Store<T>, owner: () => void): boolean { | |
| */ | ||
| function scheduleStoreEviction(key: string, store: Store<unknown>) { | ||
| clearPollTimer(store); | ||
| store.pollSuspended = false; | ||
| // The visibility listener exists to wake a poll; with no poll left there is nothing to | ||
| // wake, and leaving it attached would leak one handler per evicted store. | ||
| removeVisibilityListener(store); | ||
|
|
@@ -553,3 +618,13 @@ export function clearClientResourceStoresForTests(): void { | |
| } | ||
| stores.clear(); | ||
| } | ||
|
|
||
| /** | ||
| * Test-only: whether a live poll timer exists for the key. Suspension (hidden with no | ||
| * opt-out subscriber) means NO timer — that absence is the whole hidden-tab guarantee, | ||
| * and fetch counts alone cannot see it (skipped ticks look identical). | ||
| */ | ||
| export function hasPollTimerForTests(key: string): boolean { | ||
| const store = stores.get(key); | ||
| return store ? store.pollTimer !== null : false; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ import type { | |
| } from "./add-codex-account-reducer"; | ||
| import type { TFn } from "../i18n/shared"; | ||
| import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; | ||
| import { startVisibilityPoll } from "../visibility-poll"; | ||
| import { | ||
| codexAccountMutationCompletion, | ||
| type CodexAccountMutationCompletion, | ||
|
|
@@ -34,7 +35,7 @@ export function useAddCodexAccountOAuth({ | |
| const aliveRef = useRef(true); | ||
| const pollErrorStreakRef = useRef(0); | ||
| const pollInFlightRef = useRef(false); | ||
| const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); | ||
| const pollRef = useRef<(() => void) | null>(null); | ||
| const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
| const pollAbortRef = useRef<AbortController | null>(null); | ||
| const flowRef = useRef<string | null>(null); | ||
|
|
@@ -55,7 +56,7 @@ export function useAddCodexAccountOAuth({ | |
| }, [ui.flowId]); | ||
|
|
||
| const stopPolling = useCallback(() => { | ||
| if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } | ||
| if (pollRef.current) { pollRef.current(); pollRef.current = null; } | ||
| if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } | ||
| pollAbortRef.current?.abort(); | ||
| pollAbortRef.current = null; | ||
|
|
@@ -174,7 +175,9 @@ export function useAddCodexAccountOAuth({ | |
| : `${apiBase}/api/codex-auth/login-status`; | ||
| const pollSession = new AbortController(); | ||
| pollAbortRef.current = pollSession; | ||
| pollRef.current = setInterval(async () => { | ||
| // A hidden tab cannot complete OAuth; the visible make-up tick checks the | ||
| // login status the moment the user returns to the modal. | ||
| pollRef.current = startVisibilityPoll(async () => { | ||
|
Comment on lines
+178
to
+180
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the user follows the OAuth URL in another tab, the dashboard becomes hidden while the provider callback can still complete the server-side flow. Pausing this poll prevents the completed status from clearing the five-minute timeout at line 238; if the user remains on the provider's completion page until that timeout fires, the dashboard calls Useful? React with 👍 / 👎. |
||
| if (pollInFlightRef.current || pollSession.signal.aborted) return; | ||
| pollInFlightRef.current = true; | ||
| // Bound each tick and abort it when stopPolling/cleanup cancels the session. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Correct the landed-date range.
If this heading records completed work,
2026-08-17is future-dated relative to August 16, 2026. Use2026-08-16, or label August 17, 2026 as planned.🤖 Prompt for AI Agents