fix(client): finishing a long background thread no longer freezes the app#33503
Problem
A desktop performance trace showed two main-thread freezes of 1.7s and 1.6s while the user was typing in the composer. The keystrokes queued up and replayed afterwards (16 at once after the first freeze).
Both freezes had the same cause. A thread that had been running in the background finished. runningThreadKeepAliveAtom stopped mounting its live state, the registry removed the atom on the next setTimeout(0), and the thread-state scope finalizer saved the entire live projection to the thread cache in one task:
| Step | Cost per freeze |
|---|---|
Schema encode (Schema.fromJsonString(StoredOrchestrationThreadSnapshot)) | ~0.35–0.7s |
| JSON stringify | ~0.3–0.7s |
IDBObjectStore.put copying the string | ~0.5–0.6s |
The JS heap grew by about 300 MB during one save (618 → 915 MB). A thread followed live keeps every item it streams, so the cached copy can reach hundreds of MB. The periodic save after a run settles, and leaving a large thread, run the same code.
#29417 reports the same profile from the periodic save after each settled run: a 47 MB thread re-encoded every 30–50s while the user is in a different thread.
Fixes pingdotgg/t3code#29417
Fix
Thread cache saves now go through makeStoredThreadSnapshotEncoder, built like the existing shell cache encoder (e2b929f):
- It remembers the encoding of each turn item and timeline row, so a re-save only encodes rows that changed.
- It encodes new rows 32 at a time and yields to the host between chunks. Web uses the existing
MessageChannelyield. - It adds up the JSON size as it goes and stops once the rows pass 4M characters. A thread over the cap is not cached, and its older cached copy is removed, so a stale version can't show on reopen. Reopening that thread loads the server's bounded window, the same path threads with expanded history already take.
Because a save now yields, it can finish after a later save of the same thread, a removal, or an environment clear. Each of these takes a ticket when it starts (makeThreadCacheWriteOrder), and a save writes only while no newer ticket exists for its thread or environment, so the newest operation always wins.
The output is byte-identical to the old encoders. Web keeps the full format and mobile the compact one, so no cache schema version changes. The host-yield helper moved to platform/hostYield.ts so both encoders can share it.
Considered and not done:
- Moving the encode to a Web Worker.
postMessagestructured-clones the projection on the main thread, which costs about as much as the encode it moves, and with the cap the longest remaining slice is about 10ms. - Storing objects instead of JSON strings in IndexedDB. Once encoding stops blocking, this doesn't help saves, and loading would probably be slower, because
JSON.parsebeats IndexedDB decoding stored objects on the main thread.
Verification
Benchmark in Node. Each row compares the old one-shot encode with the longest synchronous slice between yields in the new encoder:
| Thread | Before (one blocking step) | After: longest slice | After: warm re-save |
|---|---|---|---|
| ~3.5M chars, 400 items | 35ms | 9.4ms | 9ms total |
| ~3.2M chars, 2,000 items | 54ms | 9.1ms | 10ms total |
| ~122M chars, 3,000 items (size of the traced thread) | 528ms (1.6–1.7s in the Electron trace, including the IndexedDB write) | 0.6ms, stops after 6 chunks and writes nothing | 0ms |
- New
threadCacheEncoding.test.ts:- The full format is byte-identical to the plain schema encode across chunk boundaries, both cold and after items are edited or added.
- The compact format round-trips.
- Both formats encode nothing once over the cap.
- New mobile store tests (
environment-cache-store.test.ts):- A save still encoding doesn't write after a removal, an environment clear, or a newer save. This fails with the ordering check disabled.
- A thread that grows past the cap has its older cached copy removed.
- Existing web store tests (
storage.test.ts,storage.browser.test.ts) and mobile store tests pass. vp run check:changedpasses.
Screenshots don't apply: nothing visible changes, only main-thread blocking.
Remaining limits
- The renderer heap was already about 600 MB before the first freeze. Threads kept loaded by
runningThreadKeepAliveAtomhold their whole live projection in memory. That needs a heap snapshot to investigate and is separate from this change. - In the full (web) format, each local item is encoded and cached twice: once as a turn item and once inside its timeline row. That memory is bounded by the cap. Building each row from its turn item's cached encoding would roughly halve the work and the memory, and can be a follow-up.
- Each shell update rebuilds
savedPullRequestSummariesAtomover every thread shell, about 12ms per update in the trace. Also separate.
🤖 Generated with Claude Code (Claude Opus 5.5)
Checks
Failing1
- CodeRabbitGitHubCancelled
Added 1 commit
- fix(client): finishing a long background thread no longer freezes the app4ec957c
Approvability
Verdict: Needs Human Approval
Macroscope's review found this PR requires human approval — Chunked, memoized encoding reduces thread-cache freezes, but the new size cap also removes cached copies of large threads. Human review should confirm the threshold and the resulting loss of cached content when reopening without connectivity.
You can add or adjust custom eligibility rules. Learn more.
No actionable comments were generated in the recent review.
Walkthrough
-
Thread snapshot encoding now supports full and compact formats, chunked row processing, host yields, and a configurable JSON-length limit. Mobile and web cache writes remove existing entries when encoding produces no payload.
Changes: Thread cache snapshots
Layer / File(s) Summary Chunked snapshot encoding
packages/client-runtime/src/platform/threadCacheEncoding.ts,packages/client-runtime/src/platform/hostYield.ts,packages/client-runtime/src/platform/shellCacheEncoding.ts,packages/client-runtime/src/platform/threadCacheEncoding.test.tsThe encoder supports full and compact formats, encodes uncached rows in chunks of 32, yields between chunks, and returns no payload when the configured JSON-length limit is exceeded. Tests cover encoding equivalence, compact round trips, and size limits. Mobile and web cache writes
apps/mobile/src/connection/environment-cache-store.ts,apps/web/src/connection/storage.tsMobile configures compact encoding; web configures full encoding. Both remove the cached thread when encoding returns no payload and save the payload when encoding succeeds. Estimated code review effort: 3 (Moderate) | ~25 minutes
Change: Bug fix
Possibly related issues
- pingdotgg/t3code#29417 — Reports main-thread freezes during
storage.saveThreadwhen encoding large thread snapshots. This PR adds chunked encoding and host yields. - pingdotgg/t3code#9661 — Tracks caches without size or age eviction. This PR limits the encoded thread cache payload size.
Possibly related PRs
- pingdotgg/t3code#24265 — Adds the compact mobile thread-cache encoder that this PR replaces with a configurable encoder.
- pingdotgg/t3code#31483 — Changes shared thread-cache encoding formats that this PR also handles.
- pingdotgg/t3code#31484 — Includes shared thread-cache encoding changes and updates the web save path.
Suggested labels:
bugfix,size:MSuggested reviewers:
macroscopeapp,t3dotgg - pingdotgg/t3code#29417 — Reports main-thread freezes during
Merge Risk: External image Minimal · up to 4ec95
-
No actionable issue is established by the available evidence. The change is mergeable after normal checks.
Pre-merge checks: Passed 3 · Failed 1
-
Failed Failed checks: 1 warning
Check name Status Explanation Resolution Description check Warning The description clearly explains the problem, the change, and focused verification. It links issue #29417but does not provide the required maintainer approval of the direction and scope, including an…Add the triaged issue or discussion link and the maintainer’s explicit approval comment for this change’s direction and scope. If approval is not required, explain why this change qualifies for an exception under the repository template. Passed Passed checks: 3 passed
-
Check name Status Explanation Title check Passed The title clearly summarizes the main change: preventing freezes when a long background thread finishes. Linked Issues check Passed Check skipped because no linked issues were found for this pull request. Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check
-
Explanation
The description clearly explains the problem, the change, and focused verification. It links issue
#29417but does not provide the required maintainer approval of the direction and scope, including an approval comment.Resolution
Add the triaged issue or discussion link and the maintainer’s explicit approval comment for this change’s direction and scope. If approval is not required, explain why this change qualifies for an exception under the repository template.
-
Finishing Touches
-
Generate docstrings
-
- Commit to this branch
- Create a new PR
Generate unit tests (beta)
-
- Commit to this branch
- Create a new PR
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
External image Recent review info
-
External image Run configuration
-
- Source mode: Auto
- Repository: pingdotgg/t3code
- Branch: t3/thread-cache-size-cap
- File: .coderabbit.config.ts
- Commit: 4ec957c
- Layers: Repository TS config
- Review profile: CHILL
- Plan: Team
- Run ID:
5d5690ed-23f2-4f2c-8244-1b219460490c
External image Commits
-
Reviewing files that changed from the base of the PR and between 6b02cbff3a0cd9a0f2c97007562a1ce74fd35aa0 and 4ec957c8ab22fb43451d24e0b29cecb728c523ba.
External image Files selected for processing: 6
-
apps/mobile/src/connection/environment-cache-store.tsapps/web/src/connection/storage.tspackages/client-runtime/src/platform/hostYield.tspackages/client-runtime/src/platform/shellCacheEncoding.tspackages/client-runtime/src/platform/threadCacheEncoding.test.tspackages/client-runtime/src/platform/threadCacheEncoding.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.
External image Recent review info
-
External image Run configuration
-
- Source mode: Auto
- Repository: pingdotgg/t3code
- Branch: t3/thread-cache-size-cap
- File: .coderabbit.config.ts
- Commit: 4ec957c
- Layers: Repository TS config
- Review profile: CHILL
- Plan: Team
- Run ID:
5d5690ed-23f2-4f2c-8244-1b219460490c
External image Commits
-
Reviewing files that changed from the base of the PR and between 6b02cbff3a0cd9a0f2c97007562a1ce74fd35aa0 and 4ec957c8ab22fb43451d24e0b29cecb728c523ba.
External image Files selected for processing: 6
-
apps/mobile/src/connection/environment-cache-store.tsapps/web/src/connection/storage.tspackages/client-runtime/src/platform/hostYield.tspackages/client-runtime/src/platform/shellCacheEncoding.tspackages/client-runtime/src/platform/threadCacheEncoding.test.tspackages/client-runtime/src/platform/threadCacheEncoding.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.
-
Added 1 commit
- fix(client): a thread save that a removal or newer save overtook no longer writes0b7b299