fix(client): finishing a long background thread no longer freezes the app#33503

merged2
main
t3/thread-cache-size-cap
opened11h agobyJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 9h ago

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:

StepCost 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 MessageChannel yield.
  • 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. postMessage structured-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.parse beats 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:

ThreadBefore (one blocking step)After: longest sliceAfter: warm re-save
~3.5M chars, 400 items35ms9.4ms9ms total
~3.2M chars, 2,000 items54ms9.1ms10ms 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 nothing0ms
  • 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:changed passes.

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 runningThreadKeepAliveAtom hold 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 savedPullRequestSummariesAtom over every thread shell, about 12ms per update in the trace. Also separate.

🤖 Generated with Claude Code (Claude Opus 5.5)

Checks

1 failing · 23 successful · 15 skipped · 1 neutral
Some checks failed

Failing1

0b7b299

Added 1 commit

  • fix(client): finishing a long background thread no longer freezes the app4ec957c
Synced from GitHub

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.

Synced from GitHub

Explain Change →

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.ts
The 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.ts
Mobile 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.saveThread when 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

Suggested labels: bugfix, size:M

Suggested reviewers: macroscopeapp, t3dotgg

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 nameStatusExplanationResolution
Description checkWarningThe description clearly explains the problem, the change, and focused verification. It links issue #29417 but 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 nameStatusExplanation
Title checkPassedThe title clearly summarizes the main change: preventing freezes when a long background thread finishes.
Linked Issues checkPassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes checkPassedCheck 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 #29417 but 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

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.ts
  • apps/web/src/connection/storage.ts
  • packages/client-runtime/src/platform/hostYield.ts
  • packages/client-runtime/src/platform/shellCacheEncoding.ts
  • packages/client-runtime/src/platform/threadCacheEncoding.test.ts
  • packages/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