From 7e6782abdeeee6185780a50923c0d225b9effd86 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 15:18:54 -0700 Subject: [PATCH 1/3] Add tests for an injectable clock in the timer-backstop chat-ui hooks useTurnActivity's and useStreamingReply's backstop tests drove real setTimeout via sleep(), racing CI's sharded, CPU-contended test runs against their 20-60ms windows. Rework the harnesses in both suites to advance a synchronous fake clock instead. --- packages/chat-ui/test/fake-clock.ts | 61 +++++++++++++++++++ .../chat-ui/test/use-streaming-reply.test.tsx | 8 +-- .../chat-ui/test/use-turn-activity.test.tsx | 7 ++- 3 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 packages/chat-ui/test/fake-clock.ts diff --git a/packages/chat-ui/test/fake-clock.ts b/packages/chat-ui/test/fake-clock.ts new file mode 100644 index 0000000000..69c99714cd --- /dev/null +++ b/packages/chat-ui/test/fake-clock.ts @@ -0,0 +1,61 @@ +// A synchronous stand-in for `../src/clock.ts`'s `REAL_CLOCK`: `advance` +// fires every due timer against a virtual clock with no real sleep, so a +// test asserting a 30ms backstop actually fired takes 0ms of wall time +// instead of racing CI's CPU-contended shards for those 30ms (CL-7488). +import type { Clock } from "../src/clock"; + +export type FakeClock = { + readonly clock: Clock; + /** Moves the virtual clock forward by `ms`, firing (in fire-time order) + * every timer due at or before the new time — including one a firing + * timer's own callback re-arms within this same window, mirroring how a + * real timer queue drains. */ + readonly advance: (ms: number) => void; +}; + +export function createFakeClock(): FakeClock { + let now = 0; + let nextId = 1; + const timers = new Map void }>(); + + function fakeSetTimeout(callback: () => void, ms: number): number { + const id = nextId++; + timers.set(id, { fireAt: now + ms, callback }); + return id; + } + + function fakeClearTimeout(handle: unknown): void { + timers.delete(handle as number); + } + + function advance(ms: number): void { + const target = now + ms; + for (;;) { + let dueId: number | null = null; + let dueEntry: { fireAt: number; callback: () => void } | null = null; + for (const [id, entry] of timers) { + if ( + entry.fireAt <= target && + (dueEntry === null || entry.fireAt < dueEntry.fireAt) + ) { + dueId = id; + dueEntry = entry; + } + } + if (dueId === null || dueEntry === null) break; + timers.delete(dueId); + now = dueEntry.fireAt; + dueEntry.callback(); + } + now = target; + } + + return { + clock: { + now: () => now, + setTimeout: fakeSetTimeout, + clearTimeout: fakeClearTimeout, + }, + advance, + }; +} diff --git a/packages/chat-ui/test/use-streaming-reply.test.tsx b/packages/chat-ui/test/use-streaming-reply.test.tsx index 37278b5c7c..a42430ead1 100644 --- a/packages/chat-ui/test/use-streaming-reply.test.tsx +++ b/packages/chat-ui/test/use-streaming-reply.test.tsx @@ -10,8 +10,7 @@ import { createRoot } from "react-dom/client"; import { useStreamingReply } from "../src/streaming-reply"; import type { StreamingReplyState } from "../src/streaming-reply"; - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +import { createFakeClock } from "./fake-clock"; function mount( initialWorkbenchId: string | null, @@ -21,6 +20,7 @@ function mount( const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); + const fakeClock = createFakeClock(); let latestState: StreamingReplyState = null; let latestTimedOutRefId: string | null = null; let send: (eventType: string, data: unknown) => void = () => {}; @@ -39,7 +39,7 @@ function mount( handleStreamEvent, noteAwaitingReply, resumeFromTurn, - } = useStreamingReply(workbenchId, clearMs, minVisibleMs); + } = useStreamingReply(workbenchId, clearMs, minVisibleMs, fakeClock.clock); latestState = streamingReply; latestTimedOutRefId = replyTimedOutRefId; send = handleStreamEvent; @@ -71,7 +71,7 @@ function mount( act(() => { resume(runningTurn); }), - settle: (ms: number) => act(() => sleep(ms)), + settle: (ms: number) => act(() => fakeClock.advance(ms)), get: () => latestState, timedOutRefId: () => latestTimedOutRefId, unmount: () => act(() => root.unmount()), diff --git a/packages/chat-ui/test/use-turn-activity.test.tsx b/packages/chat-ui/test/use-turn-activity.test.tsx index 3370e3782b..11c1ccccaa 100644 --- a/packages/chat-ui/test/use-turn-activity.test.tsx +++ b/packages/chat-ui/test/use-turn-activity.test.tsx @@ -10,13 +10,13 @@ import { act, createElement, useState } from "react"; import { createRoot } from "react-dom/client"; import { useTurnActivity, TurnActivityStrip } from "../src/turn-activity"; - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +import { createFakeClock } from "./fake-clock"; function mount(initialWorkbenchId: string | null, staleMs?: number) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); + const fakeClock = createFakeClock(); let send: (eventType: string, data: unknown) => void = () => {}; let setWorkbenchId: (id: string | null) => void = () => {}; @@ -26,6 +26,7 @@ function mount(initialWorkbenchId: string | null, staleMs?: number) { const { activity, handleStreamEvent } = useTurnActivity( workbenchId, staleMs, + fakeClock.clock, ); send = handleStreamEvent; return createElement(TurnActivityStrip, { activity }); @@ -44,7 +45,7 @@ function mount(initialWorkbenchId: string | null, staleMs?: number) { act(() => { setWorkbenchId(id); }), - settle: (ms: number) => act(() => sleep(ms)), + settle: (ms: number) => act(() => fakeClock.advance(ms)), container, unmount: () => act(() => root.unmount()), }; From 3d296be2a7a253b5faf93a4369c9fad31d80bc1b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 15:18:54 -0700 Subject: [PATCH 2/3] chat-ui: drive timer-backstop hooks with an injected clock useStreamingReply and useTurnActivity now take an optional Clock (now/ setTimeout/clearTimeout), defaulting to the real one, instead of calling Date.now()/setTimeout directly. This is what lets their tests advance a fake clock synchronously rather than racing real timers. --- packages/chat-ui/src/clock.ts | 16 ++++++++++++++++ packages/chat-ui/src/streaming-reply.ts | 22 ++++++++++++---------- packages/chat-ui/src/turn-activity.tsx | 14 +++++++++----- 3 files changed, 37 insertions(+), 15 deletions(-) create mode 100644 packages/chat-ui/src/clock.ts diff --git a/packages/chat-ui/src/clock.ts b/packages/chat-ui/src/clock.ts new file mode 100644 index 0000000000..9b2da06c2b --- /dev/null +++ b/packages/chat-ui/src/clock.ts @@ -0,0 +1,16 @@ +// The one seam `streaming-reply.ts` and `turn-activity.tsx` read wall-clock +// time and arm backstop timers through, so a test can swap in a fake that +// advances synchronously instead of sleeping on the real clock — real-timer +// waits on these hooks' 30ms/60ms backstops are exactly what made them flake +// under CI's sharded, CPU-contended `bun run test` (CL-7488). +export type Clock = { + readonly now: () => number; + readonly setTimeout: (callback: () => void, ms: number) => unknown; + readonly clearTimeout: (handle: unknown) => void; +}; + +export const REAL_CLOCK: Clock = { + now: () => Date.now(), + setTimeout: (callback, ms) => setTimeout(callback, ms), + clearTimeout: (handle) => clearTimeout(handle as ReturnType), +}; diff --git a/packages/chat-ui/src/streaming-reply.ts b/packages/chat-ui/src/streaming-reply.ts index 5912fbf87c..a11e2f2ca9 100644 --- a/packages/chat-ui/src/streaming-reply.ts +++ b/packages/chat-ui/src/streaming-reply.ts @@ -16,6 +16,7 @@ import { useEffect, useRef, useState } from "react"; import { isAgentAddress, mentionedParticipants } from "@corbits/chat/mentions"; import { reportError } from "@corbits/error-sink"; import type { Part, ParticipantRecord } from "./api"; +import { REAL_CLOCK, type Clock } from "./clock"; import { displayNameForAddress, type AgentDisplayNames, @@ -307,6 +308,7 @@ export function useStreamingReply( workbenchId: string | null, clearMs: number = PENDING_REPLY_CLEAR_MS, minVisibleMs: number = TYPING_INDICATOR_MIN_VISIBLE_MS, + clock: Clock = REAL_CLOCK, ): { readonly streamingReply: StreamingReplyState; /** Set once the backstop above has fired for the turn just cleared — a @@ -333,17 +335,17 @@ export function useStreamingReply( null, ); const pendingSinceRef = useRef(null); - const holdTimerRef = useRef | null>(null); + const holdTimerRef = useRef(null); useEffect(() => { setStreamingReply(null); setReplyTimedOutRefId(null); pendingSinceRef.current = null; if (holdTimerRef.current !== null) { - clearTimeout(holdTimerRef.current); + clock.clearTimeout(holdTimerRef.current); holdTimerRef.current = null; } - }, [workbenchId]); + }, [workbenchId, clock]); useEffect(() => { // Arm for the whole "awaiting" phase, not just its tokenless prefix @@ -356,7 +358,7 @@ export function useStreamingReply( if (streamingReply === null || streamingReply.phase !== "awaiting") { return; } - const timer = setTimeout(() => { + const timer = clock.setTimeout(() => { // The dependency below re-arms this effect (clearing this exact // timer) the instant `streamingReply` changes, so this callback only // ever runs while it's still the same pending reply it was armed @@ -375,14 +377,14 @@ export function useStreamingReply( setReplyTimedOutRefId(refId); pendingSinceRef.current = null; }, clearMs); - return () => clearTimeout(timer); - }, [streamingReply, clearMs, workbenchId]); + return () => clock.clearTimeout(timer); + }, [streamingReply, clearMs, workbenchId, clock]); function commitReply( next: StreamingReplyState, current: StreamingReplyState, ): StreamingReplyState { - const now = Date.now(); + const now = clock.now(); const becamePending = isPendingReply(next) && !isPendingReply(current); if (becamePending) pendingSinceRef.current = now; @@ -392,10 +394,10 @@ export function useStreamingReply( pendingSinceRef.current !== null && now - pendingSinceRef.current < minVisibleMs ) { - if (holdTimerRef.current !== null) clearTimeout(holdTimerRef.current); + if (holdTimerRef.current !== null) clock.clearTimeout(holdTimerRef.current); const remaining = minVisibleMs - (now - pendingSinceRef.current); const held = next; - holdTimerRef.current = setTimeout(() => { + holdTimerRef.current = clock.setTimeout(() => { holdTimerRef.current = null; pendingSinceRef.current = null; setStreamingReply(held); @@ -404,7 +406,7 @@ export function useStreamingReply( } if (holdTimerRef.current !== null) { - clearTimeout(holdTimerRef.current); + clock.clearTimeout(holdTimerRef.current); holdTimerRef.current = null; } if (!isPendingReply(next)) pendingSinceRef.current = null; diff --git a/packages/chat-ui/src/turn-activity.tsx b/packages/chat-ui/src/turn-activity.tsx index 97d66560b6..1579f722ed 100644 --- a/packages/chat-ui/src/turn-activity.tsx +++ b/packages/chat-ui/src/turn-activity.tsx @@ -15,6 +15,7 @@ import { useEffect, useState } from "react"; +import { REAL_CLOCK, type Clock } from "./clock"; import { describeToolCall, resolveToolIdentity, @@ -336,11 +337,14 @@ const TURN_ACTIVITY_STALE_MS = 120_000; * workbench switch — activity from the workbench just left belongs to that * workbench, not the new one. `staleMs` (default `TURN_ACTIVITY_STALE_MS`) * is a test seam, mirroring `useTypingIndicator`'s own configurable - * timeout. + * timeout. `clock` (default `REAL_CLOCK`) is the same kind of seam for the + * wall-clock reads and the backstop timer itself — a test drives both + * synchronously with a fake clock instead of sleeping on the real one. */ export function useTurnActivity( workbenchId: string | null, staleMs: number = TURN_ACTIVITY_STALE_MS, + clock: Clock = REAL_CLOCK, ): { readonly activity: TurnActivityState; readonly handleStreamEvent: (eventType: string, data: unknown) => void; @@ -358,15 +362,15 @@ export function useTurnActivity( // thing that ever clears it in that case. useEffect(() => { if (activity === null) return; - const timer = setTimeout(() => { + const timer = clock.setTimeout(() => { setActivity((current) => (current === activity ? null : current)); }, staleMs); - return () => clearTimeout(timer); - }, [activity, staleMs]); + return () => clock.clearTimeout(timer); + }, [activity, staleMs, clock]); function handleStreamEvent(eventType: string, data: unknown) { setActivity((current) => - nextTurnActivityState(current, { eventType, data }, Date.now()), + nextTurnActivityState(current, { eventType, data }, clock.now()), ); } From 5fc72f5169f2454f98ba704c3146661e5fff7b7c Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 15:22:45 -0700 Subject: [PATCH 3/3] Reformat clock.ts and streaming-reply.ts with prettier --- packages/chat-ui/src/clock.ts | 3 ++- packages/chat-ui/src/streaming-reply.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/chat-ui/src/clock.ts b/packages/chat-ui/src/clock.ts index 9b2da06c2b..9e049fe2aa 100644 --- a/packages/chat-ui/src/clock.ts +++ b/packages/chat-ui/src/clock.ts @@ -12,5 +12,6 @@ export type Clock = { export const REAL_CLOCK: Clock = { now: () => Date.now(), setTimeout: (callback, ms) => setTimeout(callback, ms), - clearTimeout: (handle) => clearTimeout(handle as ReturnType), + clearTimeout: (handle) => + clearTimeout(handle as ReturnType), }; diff --git a/packages/chat-ui/src/streaming-reply.ts b/packages/chat-ui/src/streaming-reply.ts index a11e2f2ca9..a32d6b819d 100644 --- a/packages/chat-ui/src/streaming-reply.ts +++ b/packages/chat-ui/src/streaming-reply.ts @@ -394,7 +394,8 @@ export function useStreamingReply( pendingSinceRef.current !== null && now - pendingSinceRef.current < minVisibleMs ) { - if (holdTimerRef.current !== null) clock.clearTimeout(holdTimerRef.current); + if (holdTimerRef.current !== null) + clock.clearTimeout(holdTimerRef.current); const remaining = minVisibleMs - (now - pendingSinceRef.current); const held = next; holdTimerRef.current = clock.setTimeout(() => {