Skip to content

Commit fc4381d

Browse files
committed
fix(webapp): keep the agent wake poll to one chain per tab
1 parent 9b4e473 commit fc4381d

3 files changed

Lines changed: 185 additions & 28 deletions

File tree

apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx

Lines changed: 17 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,19 @@ import {
1919
readAgentFullscreen,
2020
writeAgentFullscreen,
2121
} from "./panel-layout";
22+
import { startWakePolling } from "./wake-poll";
2223
import {
2324
showWatchWakesSummaryToast,
2425
showWatchWakeToast,
2526
WAKE_TOAST_MAX_INDIVIDUAL,
2627
type WatchWake,
2728
} from "./WatchWakeToast";
2829

29-
const UNREAD_POLL_INTERVAL_MS = 60_000;
30-
31-
// Added to each delay so open tabs never settle into polling on the same second.
32-
const UNREAD_POLL_JITTER_MS = 15_000;
33-
3430
const TOASTED_WAKES_STORAGE_KEY = "tdev:dashboard-agent:toasted-wakes";
3531

32+
// Shorter than the poll interval, so a stuck request is dropped before the next tick.
33+
const UNREAD_REQUEST_TIMEOUT_MS = 30_000;
34+
3635
/** `hasAccess` is a UI gate only; the resource routes enforce the same check server-side. */
3736
export function DashboardAgent({
3837
children,
@@ -143,7 +142,10 @@ export function DashboardAgent({
143142
let cancelled = false;
144143
const load = async () => {
145144
try {
146-
const res = await fetch(`${actionPath}?unread=1`);
145+
// Bounded, so one stuck request can't hold the poll's in-flight guard.
146+
const res = await fetch(`${actionPath}?unread=1`, {
147+
signal: AbortSignal.timeout(UNREAD_REQUEST_TIMEOUT_MS),
148+
});
147149
if (!res.ok) return;
148150
const data = (await res.json()) as { unreadWakes?: number; wakes?: WatchWake[] };
149151
if (cancelled) return;
@@ -168,31 +170,18 @@ export function DashboardAgent({
168170
}
169171
};
170172

171-
let timer: number | undefined;
172-
function schedule() {
173-
timer = window.setTimeout(
174-
tick,
175-
UNREAD_POLL_INTERVAL_MS + Math.random() * UNREAD_POLL_JITTER_MS
176-
);
177-
}
178-
async function tick() {
179-
// A hidden tab asks nothing; `onVisible` catches it up.
180-
if (!document.hidden) await load();
181-
if (!cancelled) schedule();
182-
}
183-
const onVisible = () => {
184-
if (document.hidden || cancelled) return;
185-
window.clearTimeout(timer);
186-
void tick();
187-
};
173+
const stop = startWakePolling({
174+
load,
175+
isHidden: () => document.hidden,
176+
onVisibilityChange: (listener) => {
177+
document.addEventListener("visibilitychange", listener);
178+
return () => document.removeEventListener("visibilitychange", listener);
179+
},
180+
});
188181

189-
void load();
190-
schedule();
191-
document.addEventListener("visibilitychange", onVisible);
192182
return () => {
193183
cancelled = true;
194-
window.clearTimeout(timer);
195-
document.removeEventListener("visibilitychange", onVisible);
184+
stop();
196185
};
197186
}, [hasAccess, actionPath, setPanelOpen, openChat]);
198187

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { startWakePolling, UNREAD_POLL_INTERVAL_MS } from "./wake-poll";
3+
4+
function harness() {
5+
let hidden = false;
6+
const listeners = new Set<() => void>();
7+
const loads: number[] = [];
8+
9+
const stop = startWakePolling({
10+
load: async () => {
11+
loads.push(Date.now());
12+
},
13+
isHidden: () => hidden,
14+
onVisibilityChange: (listener) => {
15+
listeners.add(listener);
16+
return () => listeners.delete(listener);
17+
},
18+
// No jitter, so every delay is exactly one interval.
19+
random: () => 0,
20+
setTimer: (callback, ms) => setTimeout(callback, ms) as unknown as number,
21+
clearTimer: (handle) => clearTimeout(handle as unknown as NodeJS.Timeout),
22+
});
23+
24+
return {
25+
loads,
26+
stop,
27+
setHidden(next: boolean) {
28+
hidden = next;
29+
for (const listener of listeners) listener();
30+
},
31+
listenerCount: () => listeners.size,
32+
};
33+
}
34+
35+
describe("startWakePolling", () => {
36+
beforeEach(() => {
37+
vi.useFakeTimers();
38+
});
39+
afterEach(() => {
40+
vi.useRealTimers();
41+
});
42+
43+
it("polls once immediately and then once per interval", async () => {
44+
const poll = harness();
45+
46+
expect(poll.loads).toHaveLength(1);
47+
await vi.advanceTimersByTimeAsync(UNREAD_POLL_INTERVAL_MS * 3);
48+
expect(poll.loads).toHaveLength(4);
49+
50+
poll.stop();
51+
});
52+
53+
it("asks nothing while hidden and catches up once when visible again", async () => {
54+
const poll = harness();
55+
poll.setHidden(true);
56+
57+
await vi.advanceTimersByTimeAsync(UNREAD_POLL_INTERVAL_MS * 3);
58+
expect(poll.loads).toHaveLength(1);
59+
60+
poll.setHidden(false);
61+
await vi.advanceTimersByTimeAsync(0);
62+
expect(poll.loads).toHaveLength(2);
63+
64+
poll.stop();
65+
});
66+
67+
it("keeps exactly one chain across ten rapid hide/show cycles", async () => {
68+
const poll = harness();
69+
70+
for (let cycle = 0; cycle < 10; cycle++) {
71+
poll.setHidden(true);
72+
await vi.advanceTimersByTimeAsync(10);
73+
poll.setHidden(false);
74+
await vi.advanceTimersByTimeAsync(10);
75+
}
76+
77+
const afterCycles = poll.loads.length;
78+
await vi.advanceTimersByTimeAsync(UNREAD_POLL_INTERVAL_MS * 10);
79+
80+
// One poll per interval, not ten: the resumes replaced the chain instead of
81+
// forking it.
82+
expect(poll.loads.length - afterCycles).toBe(10);
83+
84+
poll.stop();
85+
});
86+
87+
it("stops every timer and listener on unmount", async () => {
88+
const poll = harness();
89+
poll.setHidden(true);
90+
poll.setHidden(false);
91+
92+
poll.stop();
93+
const settled = poll.loads.length;
94+
95+
await vi.advanceTimersByTimeAsync(UNREAD_POLL_INTERVAL_MS * 10);
96+
expect(poll.loads).toHaveLength(settled);
97+
expect(poll.listenerCount()).toBe(0);
98+
expect(vi.getTimerCount()).toBe(0);
99+
});
100+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* The wake feed's poll: one self-scheduling chain per mount. A hidden tab asks nothing, a
3+
* resume catches up once, and neither can fork the chain into a second one.
4+
*/
5+
6+
export const UNREAD_POLL_INTERVAL_MS = 60_000;
7+
8+
// Added to each delay so open tabs never settle into polling on the same second.
9+
export const UNREAD_POLL_JITTER_MS = 15_000;
10+
11+
export type WakePollOptions = {
12+
load: () => Promise<void>;
13+
isHidden: () => boolean;
14+
/** Subscribe to visibility changes; returns its own unsubscribe. */
15+
onVisibilityChange: (listener: () => void) => () => void;
16+
/** Seams so a test can drive the chain without real timers. */
17+
random?: () => number;
18+
setTimer?: (callback: () => void, delayMs: number) => number;
19+
clearTimer?: (handle: number) => void;
20+
};
21+
22+
/** Start polling. The returned function stops the chain for good. */
23+
export function startWakePolling(options: WakePollOptions): () => void {
24+
const random = options.random ?? Math.random;
25+
const setTimer = options.setTimer ?? ((callback, ms) => window.setTimeout(callback, ms));
26+
const clearTimer = options.clearTimer ?? ((handle) => window.clearTimeout(handle));
27+
28+
let stopped = false;
29+
let timer: number | undefined;
30+
let loading = false;
31+
// Each tick carries the chain it belongs to, so an orphaned callback returns
32+
// instead of scheduling itself again.
33+
let chain = 0;
34+
35+
const tick = (generation: number) => {
36+
if (stopped || generation !== chain) return;
37+
38+
// Scheduled before the load, so a slow response can't stall the chain.
39+
timer = setTimer(
40+
() => tick(generation),
41+
UNREAD_POLL_INTERVAL_MS + random() * UNREAD_POLL_JITTER_MS
42+
);
43+
44+
if (loading || options.isHidden()) return;
45+
loading = true;
46+
const done = () => {
47+
loading = false;
48+
};
49+
options.load().then(done, done);
50+
};
51+
52+
const unsubscribe = options.onVisibilityChange(() => {
53+
if (stopped || options.isHidden()) return;
54+
// One catch-up fetch on a new chain, replacing the pending timer rather than
55+
// adding a second chain.
56+
if (timer !== undefined) clearTimer(timer);
57+
chain += 1;
58+
tick(chain);
59+
});
60+
61+
tick(chain);
62+
63+
return () => {
64+
stopped = true;
65+
if (timer !== undefined) clearTimer(timer);
66+
unsubscribe();
67+
};
68+
}

0 commit comments

Comments
 (0)