Skip to content

Stop stale fetches from overwriting fresher cache writes - #6614

Merged
ymichael merged 3 commits into
mainfrom
bb/fix-stale-response-cache-races-from-the-cache-wr-thr_6jchqxrk2u
Oct 30, 2026
Merged

ymichael merged 3 commits into
mainfrom
bb/fix-stale-response-cache-races-from-the-cache-wr-thr_6jchqxrk2u

Conversation

@ymichael

Copy link
Copy Markdown
Collaborator

Human comments

What was wrong

Several cache owners wrote a server reply or the thread-detail bootstrap straight into the React Query cache with setQueryData while a fetch of the same key could still be in flight. React Query writes fetch results without checking whether they're older than what's cached (query-core query.ts), so the older fetch landed last and put stale data back. Effects included: an environment or host status that stayed stale after opening a thread, a plugin settings form resetting its draft to the old value, a cancelled plugin install flipping back to running, new plugin update jobs dropping out of the list, an idle app update phase switching off the progress poll, and the AI service selection flickering back. These are items #2–#10 from the direct cache write audit (@thread:thr_fjr5x8dzaz). #1 (thread tabs) is fixed separately in #6611.

What changed

Two fixes, depending on whether the data has a version field:

  • Environment and Host keep the newer copy by updatedAt (Milestone 1: shared foundation plus Linux support #2, Add sidebar row context menus #3, [codex] add dev auto multi-stack runner #10). The bootstrap ingest (thread-detail-cache-owner.ts), the rename reply (applyEnvironmentUpdateResult), and the useEnvironment/useHosts fetches all compare updatedAt. The fetch-side check is what catches a fetch that started before the write and lands after it. To make it testable, those fetches now live in environmentQueryOptions and hostsQueryOptions. Host status comes from live daemon sessions and a disconnect doesn't change updatedAt, so on a tie the cached copy wins over the bootstrap. The bootstrap only fills an empty cache or replaces strictly older data. Rename replies and fetch results still win ties.
  • setQueryDataOverInFlightFetch (cache-effect-utils.ts) for data with no version ([codex] Bundle Claude Code SDK CLI support file #4–[codex] Add Cmd+Enter steer sends #9): it writes the value, and if a fetch of the key is in flight it cancels that fetch (the cache keeps the written value) and refetches. With nothing in flight it's a plain write. Used by applyPluginSettingsView, applyPluginInstallJob, applyPluginUpdateJob, hydrateAppUpdateStatus, hydrateSystemVersionCache and writeCachedAiServices, so every caller gets the fix. Where the audit suggested an unconditional cancel-write-invalidate ([codex] Route new manager flow #7) or a cancel before the write (fix(agent-runtime): expose user/global ~/.claude settings to Claude SDK #8, [codex] Add Cmd+Enter steer sends #9), this helper refetches only when a fetch was actually in flight. That avoids an extra request on every write. Forced version and app-update checks update the server's cache, so the trailing unforced refetch returns the forced result.

No wire, CLI or plugin API changes.

Not covered here: audit items #11 (plugin safe mode) and #12 (prompt history), both low risk. Also not covered: a lint rule restricting direct setQueryData calls to the helper, which would stop new call sites from bringing the race back.

How you verified

  • New tests use real QueryClient/QueryObserver instances: start a fetch that resolves late with older data, do the write, let the fetch land, then check that the newer value survives (and, for the helper, that the trailing refetch runs).
    • thread-detail-cache-owner.test.ts (new): late environment and host-list fetches after a bootstrap, an older bootstrap over a newer cache, and the equal-updatedAt host status case.
    • environment-workspace-cache-owner.test.ts: a late environment fetch after a rename, and an older rename reply over a newer cache.
    • in-flight-fetch-writes.test.ts (new): one row per cache-owner write for [codex] Bundle Claude Code SDK CLI support file #4–[codex] Add Cmd+Enter steer sends #9, plus helper tests for the no-fetch case (no extra request) and a fetch of an unobserved query.
  • With each source change reverted, every race test failed on the stale data and passed once the fix was restored.
  • pnpm exec turbo run typecheck lint test --filter=@bb/app: passed (647 files, 6515 tests; 0 lint errors, no new warnings). After rebasing onto 0472409243 I re-ran the three changed test files (21 tests passed).

🤖 Generated with Claude Code

AGENT GENERATED

ymichael and others added 3 commits October 29, 2026 17:03
…results race

The thread-detail bootstrap and the environment rename reply wrote
Environment and Host data straight into their dedicated query caches,
and those queries' fetches wrote their results back unconditionally.
Whichever landed last won, so an older response could overwrite newer
data until the next realtime event.

Compare updatedAt at every write point: the rename reply and fetch
results keep the cached copy only when it is strictly newer, and the
bootstrap, a side-channel prefill, replaces the cached copy only when it
is strictly newer. Host status is derived from live sessions and a
disconnect does not bump updatedAt, so on a tie the dedicated query's
own copy wins over the bootstrap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…start replies

Saving a plugin setting, and starting or cancelling a plugin install or
update, wrote the server's reply straight into the cache. A fetch of the
same key already in flight, such as a realtime refetch or the update job
list's one-second poll, then landed with older data: the settings form
reset its draft to the old value, a cancelled install flipped back to
running, and new jobs dropped out of the list.

Route these writes through setQueryDataOverInFlightFetch: when a fetch of
the key is in flight it cancels that fetch, which reverts to the written
value, and refetches, so the cache holds the reply until a fetch started
after the write lands. With no fetch in flight it is a plain write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g fresh replies

Applying or acknowledging an app update, forcing a version check, and
changing the AI service each wrote the server's reply straight into the
cache while a focus, realtime or poll fetch of the same key could still
be in flight. When that fetch landed later it put the older value back:
an idle app update phase switched off the one-second progress poll, an
unforced version result replaced the forced one, and the AI service
selection flickered back.

Route these writes through setQueryDataOverInFlightFetch, which cancels
an in-flight fetch of the key and refetches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ymichael
ymichael marked this pull request as ready for review October 30, 2026 06:06
@ymichael
ymichael merged commit c162434 into main Oct 30, 2026
30 checks passed
@ymichael
ymichael deleted the bb/fix-stale-response-cache-races-from-the-cache-wr-thr_6jchqxrk2u branch October 30, 2026 16:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant