Fix quadratic thread-reopen replay: batch client apply + cap server resume gap - #4605
Fix quadratic thread-reopen replay: batch client apply + cap server resume gap#4605colonelpanic8 wants to merge 7 commits into
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 543cd7d. Configure here.
ApprovabilityVerdict: Needs human review This PR makes significant changes to core thread synchronization machinery - introducing server-side replay gap caps and client-side event batching with new concurrency primitives (semaphores, generation tracking, queues). While fixing a real quadratic performance issue, the architectural changes to when snapshots vs events are used and how state publications are batched warrant careful human review. You can customize Macroscope's approvability policy. Learn more. |
…h client apply + cap server resume gap)
…h client apply + cap server resume gap)
…h client apply + cap server resume gap)
…h client apply + cap server resume gap) # Conflicts: # apps/server/src/ws.ts
… and pingdotgg#4599 The published build already carried pingdotgg#4605 and pingdotgg#4599 -- stack-build-info.json on t3code/stack lists both -- but the manifest edits that admitted them were never committed here, so the recorded intent had drifted from the artifact and a rebuild would have dropped two features. Restore them at the OIDs the published build resolved, then append pingdotgg#4624. pingdotgg#4624 is closed upstream without merging; carried because the replay cost is real on this phone. Its head carries a fix past the PR: the 200ms code-highlight debounces could never fire, because a growing code block's React key encodes its source offsets and the block remounts on every delta. Also refresh two pins that the artifact had moved past (the thread-picker group and local/nix-flake), and re-audit the exception set: the group rebuild at c5a4a73 shrank the CommandPalette losses on pingdotgg#4263/pingdotgg#4258/pingdotgg#4426 from 29/28/19 to 3/3/4. pingdotgg#4590's waiver is rewritten -- its 32 missing lines are an unbuilt branch revision, not a resolution loss, because the group is still pinned at the older member head.
543cd7d to
b82f11e
Compare
Mobile shares the replay-prone subscription path fixed server/client-side in pingdotgg#4605, but its render layer re-derived and re-animated the entire thread on every state publication. Four independent fixes: - threadActivity.ts: identity-keyed caches over the derivation chain. The reducer reuses untouched message/activity objects and arrays, so per-object WeakMap caches make buildThreadFeed incremental: unchanged activities skip payload walks, JSON.stringify, and string rebuilding; message-only publications (streaming deltas) reuse the derived work log outright; publications that touch neither array return the identical feed array so downstream memos hold. Also: single numeric- timestamp sort instead of two Date-allocating sorts, a linear already-sorted fast path (the reducer maintains that order), in-place group accumulation instead of quadratic array spreads, and cached derivePendingApprovals/derivePendingUserInputs. - Streaming code highlight: debounce Shiki until the code text holds still (200ms) on both the iOS native path and the JS atom path. Every delta previously missed the full-text-keyed cache, ran a highlight pass on the Hermes thread, and (JS path) retained one atom per delta for the 5-minute idle TTL. - ThreadFeed: during backlog replay (detected via the reducer stamping thread.updatedAt with each event's original occurredAt), drop the per-commit LinearTransition layout animation and use instant instead of animated maintainScrollAtEnd. - ThreadDetailScreen: suppress streaming haptics for replayed growth so reopening a large thread doesn't buzz through the catch-up burst. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…h client apply + cap server resume gap)
…h client apply + cap server resume gap)
…h client apply + cap server resume gap)
…h client apply + cap server resume gap)
…h client apply + cap server resume gap)
…h client apply + cap server resume gap)
…h client apply + cap server resume gap)
…h client apply + cap server resume gap)
…h client apply + cap server resume gap)
b82f11e to
9ae5817
Compare
Mobile shares the replay-prone subscription path fixed server/client-side in pingdotgg#4605, but its render layer re-derived and re-animated the entire thread on every state publication. Four independent fixes: - threadActivity.ts: identity-keyed caches over the derivation chain. The reducer reuses untouched message/activity objects and arrays, so per-object WeakMap caches make buildThreadFeed incremental: unchanged activities skip payload walks, JSON.stringify, and string rebuilding; message-only publications (streaming deltas) reuse the derived work log outright; publications that touch neither array return the identical feed array so downstream memos hold. Also: single numeric- timestamp sort instead of two Date-allocating sorts, a linear already-sorted fast path (the reducer maintains that order), in-place group accumulation instead of quadratic array spreads, and cached derivePendingApprovals/derivePendingUserInputs. - Streaming code highlight: debounce Shiki until the code text holds still (200ms) on both the iOS native path and the JS atom path. Every delta previously missed the full-text-keyed cache, ran a highlight pass on the Hermes thread, and (JS path) retained one atom per delta for the 5-minute idle TTL. - ThreadFeed: during backlog replay (detected via the reducer stamping thread.updatedAt with each event's original occurredAt), drop the per-commit LinearTransition layout animation and use instant instead of animated maintainScrollAtEnd. - ThreadDetailScreen: suppress streaming haptics for replayed growth so reopening a large thread doesn't buzz through the catch-up burst. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move the pure derive steps (display message attachment mapping, preview handoff/optimistic merge, turn diff summary index, revert turn counts) out of ChatView's inline useMemos into threadTimelineModel.ts so the thread-state -> timeline pipeline can run outside the app shell (perf harnesses, tests). Behavior-identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dev-served page (/perf.html) that mounts the real MessagesTimeline with a synthetic thread and replays N streaming thread.message-sent deltas through applyThreadDetailEvent, publishing one state per macrotask to mirror the one-event-at-a-time resume replay in threads.ts. Reports wall time, React commit count/time, long tasks, and worst stall; a batch param previews what client-side coalescing buys. Measured (Firefox headless, dev build): 2000 deltas at batch=1 take ~212s wall / 4697 commits; batch=32 takes ~8s / 179 commits. Exposes @t3tools/client-runtime/state/thread-reducer for the page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- threadReplayPerf.test.ts times the applyThreadDetailEvent fold over N streaming deltas and counts per-event state publications through the real resume path (2000 deltas -> 2002 emissions today). - threadsSyncHarness.test-util.ts extracts the threads-sync fake-socket harness for reuse. - acp-mock-agent gains T3_ACP_PROMPT_RESPONSE_CHUNK_COUNT / _CHUNK_TEXT / T3_ACP_PROMPT_CHUNK_DELAY_MS to emit a response as many small agent_message_chunk updates, so a fake provider can generate heavy streaming histories end to end when enableAssistantStreaming is on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Real agentic threads are dominated by thread.activity-appended events (e.g. 4170 of 4830 in a sampled thread) even with assistant streaming off, and the reducer re-sorts the whole activities array per event. Add an activities param that interleaves tool activity events with message deltas to reproduce that mix: 350+4200 events at batch=1 take ~37s / 7114 commits; batch=32 takes ~5.4s / 325 commits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eplay linear Route subscription items through a queue and fold each drained batch (plus a few scheduler turns of an in-flight burst) through the reducer with a single state publication, instead of one SubscriptionRef.set per event. A resume replay of thousands of persisted events previously published one state per event, re-deriving and re-rendering the full timeline each time — reopening a large thread froze the UI for tens of seconds (pingdotgg#4596). Live one-at-a-time updates still publish immediately; no timers are involved, so a quiet stream gains no latency. The threadReplayPerf gauge now enforces the collapse: 2000 replayed deltas must produce fewer than 200 publications (previously 2002). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
subscribeThread with afterSequence replayed every persisted event since the cursor, unbounded — a thread left idle through a few long agentic turns replays thousands of events on reopen (pingdotgg#4596). Mirror the shell stream's SHELL_RESUME_MAX_GAP: when the cursor is more than THREAD_RESUME_MAX_GAP (1000) behind the head (or ahead of it), send a fresh snapshot followed by the buffered live tail instead. In-range replays are now also bounded to the captured head rather than Number.MAX_SAFE_INTEGER. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9ae5817 to
5eaec3e
Compare
Mobile shares the replay-prone subscription path fixed server/client-side in pingdotgg#4605, but its render layer re-derived and re-animated the entire thread on every state publication. Four independent fixes: - threadActivity.ts: identity-keyed caches over the derivation chain. The reducer reuses untouched message/activity objects and arrays, so per-object WeakMap caches make buildThreadFeed incremental: unchanged activities skip payload walks, JSON.stringify, and string rebuilding; message-only publications (streaming deltas) reuse the derived work log outright; publications that touch neither array return the identical feed array so downstream memos hold. Also: single numeric- timestamp sort instead of two Date-allocating sorts, a linear already-sorted fast path (the reducer maintains that order), in-place group accumulation instead of quadratic array spreads, and cached derivePendingApprovals/derivePendingUserInputs. - Streaming code highlight: debounce Shiki until the code text holds still (200ms) on both the iOS native path and the JS atom path. Every delta previously missed the full-text-keyed cache, ran a highlight pass on the Hermes thread, and (JS path) retained one atom per delta for the 5-minute idle TTL. - ThreadFeed: during backlog replay (detected via the reducer stamping thread.updatedAt with each event's original occurredAt), drop the per-commit LinearTransition layout animation and use instant instead of animated maintainScrollAtEnd. - ThreadDetailScreen: suppress streaming haptics for replayed growth so reopening a large thread doesn't buzz through the catch-up burst. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…atch client apply + cap server resume gap) # Conflicts: # packages/client-runtime/src/state/threads.ts
…atch client apply + cap server resume gap) # Conflicts: # packages/client-runtime/src/state/threads.ts

Fixes #4596.
Reopening a thread with a large persisted-event backlog visibly re-replayed the whole backlog: the server streamed every event since the client's cursor individually, and the client published a new state (full timeline re-derive + re-render) per event. Real threads carry thousands of events — a sampled database had a thread with 4,830 (4,170 of them
thread.activity-appended, with assistant streaming off) — making reopen cost quadratic and freezing the UI for tens of seconds to minutes.The fix (two independent halves)
packages/client-runtime/src/state/threads.ts): subscription items now flow through a queue; the apply loop drains one item plus everything that accumulated (giving an in-flight burst a few scheduler turns to land) and folds the whole batch through the reducer with a single state publication. Live one-at-a-time updates publish immediately; no timers, so a quiet stream gains no latency.apps/server/src/ws.ts):subscribeThreadresume now mirrors the shell stream's gap cap — beyondTHREAD_RESUME_MAX_GAP(1000) events (or a cursor ahead of the head), it sends a fresh snapshot plus the live tail instead of an unbounded replay. In-range replays are bounded to the captured head instead ofNumber.MAX_SAFE_INTEGER.Evidence
threadReplayPerfgauge (added here) drives the real resume path: 2000 replayed deltas previously produced 2,002 state publications; the test now enforces < 200./perf.htmlplayground (added here) mounts the realMessagesTimelineand showed one-publication-per-event costs ~37s for a real-thread mix (350 message + 4,200 activity events) and ~212s for 2000 streaming deltas; batched application measured ~5.4s / ~8.1s respectively (7–26×).subscribeThread sends a fresh snapshot instead of replaying a large gap.Also in this PR (repro tooling, first four commits)
threadTimelineModel.ts: pure extraction of ChatView's timeline derivation (behavior-identical) so the pipeline runs outside the app shell./perf.htmldev playground replaying synthetic delta/activity backlogs through the real reducer + timeline with commit/stall metrics.threadReplayPerf.test.tsunit gauge and a chunked-streaming mode foracp-mock-agent.ts(T3_ACP_PROMPT_RESPONSE_CHUNK_COUNT) for end-to-end reproduction.Tests: client-runtime 473/473, server
server.test.ts113/113, web timeline suites 101/101; typechecks clean across touched packages.🤖 Generated with Claude Code
Note
Medium Risk
Changes core thread WebSocket resume and client state publication semantics; mitigated by mirroring the existing shell gap cap, new server/client tests, and perf guards that limit emissions during large replays.
Overview
Fixes quadratic cost when reopening threads with large persisted-event backlogs (streaming deltas and tool activities).
Server:
subscribeThreadresume now usesTHREAD_RESUME_MAX_GAP(1000), matching the shell stream. Gaps larger than that (or an invalid cursor) skip per-event replay and send a fresh thread snapshot plus the live tail; in-range catch-up reads only through the captured head instead of an unbounded range.Client: Thread subscription items go through a queue and drain-based batching loop that folds bursts into
applyItemsand one state publication per batch (live single events still publish promptly). Resubscribe clears stale queued items via a generation counter and an apply lock.Supporting changes: Timeline derivation is moved to
threadTimelineModel.ts(same behavior inChatView). Adds/perf.htmlreplay playground,threadReplayPerfbenchmarks, shared sync test harness, and optional chunked mock-agent responses for reproducing heavy streaming histories.Reviewed by Cursor Bugbot for commit 5eaec3e. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Fix quadratic thread-reopen replay by batching client state updates and capping server replay gap
threads.ts: replaces per-event state publication with drain-based batching that folds up to 32 items into a single state emit, eliminating O(n) React renders during replayws.ts: caps server-side replay atTHREAD_RESUME_MAX_GAP = 1_000events; gaps exceeding this send a fresh snapshot instead of replaying all missed eventsthreadTimelineModel.tsand uses them inChatView, enabling accurate profiling via the newThreadReplayPlaygroundMacroscope summarized 5eaec3e.