Skip to content

Fix quadratic thread-reopen replay: batch client apply + cap server resume gap - #4605

Open
colonelpanic8 wants to merge 7 commits into
pingdotgg:mainfrom
colonelpanic8:t3code/investigate-large-thread-rendering
Open

Fix quadratic thread-reopen replay: batch client apply + cap server resume gap#4605
colonelpanic8 wants to merge 7 commits into
pingdotgg:mainfrom
colonelpanic8:t3code/investigate-large-thread-rendering

Conversation

@colonelpanic8

@colonelpanic8 colonelpanic8 commented Jul 27, 2026

Copy link
Copy Markdown

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)

  • Client (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.
  • Server (apps/server/src/ws.ts): subscribeThread resume now mirrors the shell stream's gap cap — beyond THREAD_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 of Number.MAX_SAFE_INTEGER.

Evidence

  • The threadReplayPerf gauge (added here) drives the real resume path: 2000 replayed deltas previously produced 2,002 state publications; the test now enforces < 200.
  • The /perf.html playground (added here) mounts the real MessagesTimeline and 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×).
  • New server test: 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.html dev playground replaying synthetic delta/activity backlogs through the real reducer + timeline with commit/stall metrics.
  • threadReplayPerf.test.ts unit gauge and a chunked-streaming mode for acp-mock-agent.ts (T3_ACP_PROMPT_RESPONSE_CHUNK_COUNT) for end-to-end reproduction.

Tests: client-runtime 473/473, server server.test.ts 113/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: subscribeThread resume now uses THREAD_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 applyItems and 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 in ChatView). Adds /perf.html replay playground, threadReplayPerf benchmarks, 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 replay
  • ws.ts: caps server-side replay at THREAD_RESUME_MAX_GAP = 1_000 events; gaps exceeding this send a fresh snapshot instead of replaying all missed events
  • Extracts timeline derivation helpers into threadTimelineModel.ts and uses them in ChatView, enabling accurate profiling via the new ThreadReplayPlayground
  • Behavioral Change: live single events still publish promptly; resume replay now emits far fewer intermediate states, which may affect any code observing intermediate thread states during reconnect

Macroscope summarized 5eaec3e.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Jul 27, 2026
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: eaebf1df-6aa5-4d51-b2e6-5c57cdac773b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread packages/client-runtime/src/state/threads.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ 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.

Comment thread packages/client-runtime/src/state/threads.ts
Comment thread packages/client-runtime/src/state/threads.ts
@macroscopeapp

macroscopeapp Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: 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.

colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 27, 2026
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 27, 2026
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 27, 2026
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 27, 2026
…h client apply + cap server resume gap)

# Conflicts:
#	apps/server/src/ws.ts
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 27, 2026
… 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.
@colonelpanic8
colonelpanic8 force-pushed the t3code/investigate-large-thread-rendering branch from 543cd7d to b82f11e Compare July 27, 2026 11:15
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 27, 2026
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>
Comment thread apps/web/src/perf/ThreadReplayPlayground.tsx Outdated
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 27, 2026
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 27, 2026
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 27, 2026
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 27, 2026
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 27, 2026
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 27, 2026
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 27, 2026
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 27, 2026
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 28, 2026
@colonelpanic8
colonelpanic8 force-pushed the t3code/investigate-large-thread-rendering branch from b82f11e to 9ae5817 Compare July 28, 2026 02:08
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 28, 2026
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>
colonelpanic8 and others added 4 commits July 27, 2026 19:47
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>
colonelpanic8 and others added 3 commits July 27, 2026 19:47
…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>
@colonelpanic8
colonelpanic8 force-pushed the t3code/investigate-large-thread-rendering branch from 9ae5817 to 5eaec3e Compare July 28, 2026 02:51
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 28, 2026
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>
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 28, 2026
…atch client apply + cap server resume gap)

# Conflicts:
#	packages/client-runtime/src/state/threads.ts
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 28, 2026
…atch client apply + cap server resume gap)

# Conflicts:
#	packages/client-runtime/src/state/threads.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Reopening a thread with a large event backlog freezes the UI while it visibly re-replays (quadratic replay)

1 participant