Repository navigation
fix(client): reject pre-reset cache writes after resetForReconnect - #3092
liangqianxing wants to merge 11 commits into
Conversation
…rReconnect When `ClientResponseCache.resetForReconnect()` clears `_evictionGeneration`, an in-flight request from before the reset whose generation snapshot was `0` previously passed the `write()` guard (`_evictionGeneration.get(gk) ?? 0`) both when no post-reset capture had happened yet and when a post-reset request had also captured generation `0` and written. As a result, a delayed pre-reset response could populate the new server identity's partition or overwrite a fresh post-reset cache entry. Require `_evictionGeneration.get(gk) === capturedGen` (rejecting writes when no capture has occurred since construction or reset) and track keys that had a pre-reset generation `0` capture across `resetForReconnect()` so that after the post-reset generation `0` capture writes once, any delayed pre-reset generation `0` write for that key is ignored. Fixes modelcontextprotocol#3075 Co-Authored-By: Claude Code <[email protected]>
🦋 Changeset detectedLatest commit: a49eee6 The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
|
Read the diff of
A side effect of the one-write rule: two legitimate post-reset requests that both captured The root cause is that |
Replace the per-key generation-0 tracking sets with a monotonic `_generationBase` that starts at `0` and advances past the highest recorded generation when `resetForReconnect()` clears a non-empty `_evictionGeneration` map. `captureGeneration()` and `evict()` now fall back to `this._generationBase` instead of `0`, so post-reset generation snapshots are strictly greater than any pre-reset snapshot (including keys evicted before reset) while `_evictionGeneration` remains cleared on reconnect and concurrent post-reset captures can write normally. Co-Authored-By: Claude Code <[email protected]>
|
Thanks for catching those orderings — you're right that as long as I've replaced That keeps I also updated the |
|
Read One case I would add to the matrix: two resets in a row with nothing captured in between, then a stale write using a token from before the first reset. The Question: does anything outside |
|
Added the Regarding your question on token lifetime: nothing outside |
…write Co-Authored-By: Claude Code <[email protected]>
7023423 to
0892bdf
Compare
|
Read One more ordering for the matrix: a capture that is in flight while two resets happen. Request A captures at base N, reset 1 runs with A's key in the map so the base moves to N+1, a post-reset capture adds a key, and reset 2 runs and moves it to N+2 (the map is non-empty again). A then writes with its N token. By the same argument it should be rejected, but it is the only path where both resets take the |
Co-Authored-By: Claude Code <[email protected]>
|
Added On the
|
|
Read One last ordering, on |
…seCache Co-Authored-By: Claude Code <[email protected]>
|
Previously Added |
|
Read One gap in what it can catch. The final assertion is only the Reading |
Co-Authored-By: Claude Code <[email protected]>
|
Good point — Added an |
|
Read One ordering that row still skips: a stale write arriving after Would adding a stale write with a distinct payload (say |
Co-Authored-By: Claude Code <[email protected]>
|
Added a stale write with a distinct payload ( |
|
Read Both stale writes use |
|
In That's by design so two concurrent requests in the same valid epoch (with no intervening When an eviction does happen after a post-reset capture ( |
|
That corrects me: if One thing I could not tell from the diff alone: in |
Co-Authored-By: Claude Code <[email protected]>
|
Updated |
|
Read One thing left in that row: the two writes use |
…t writes Co-Authored-By: Claude Code <[email protected]>
|
Updated in
|
|
Read One thing about the sibling check: |
…s/read Co-Authored-By: Claude Code <[email protected]>
|
Added |
|
Read One related case I could not find in the tests: |
Co-Authored-By: Claude Code <[email protected]>
|
In
|
|
Read One path I could not see in the tests: |
|
Yes — |
|
That settles it, and it corrects what I wrote: with no Since that guarantee lives in the call sites rather than in |
Summary
Fixes #3075.
When
ClientResponseCache.resetForReconnect()clears_evictionGeneration, an in-flight request from before the reset whose generation snapshot was0previously passed thewrite()guard ((this._evictionGeneration.get(gk) ?? 0) !== capturedGen) in two cases:_evictionGeneration.get(gk)wasundefined, falling back via?? 0to0 === 0).0(both snapshots shared the same numeric token).Changes
packages/client/src/client/responseCache.ts:this._evictionGeneration.get(gk) === capturedGeninwrite()without the?? 0fallback so writes for keys that have not captured a generation in the current connection epoch are rejected._generationBase(starting at0and advancing tomax(this._generationBase, ...this._evictionGeneration.values()) + 1whenresetForReconnect()clears a non-empty_evictionGenerationmap) and use?? this._generationBaseincaptureGeneration()andevict(), ensuring post-reset tokens are strictly greater than any pre-reset token while still clearing_evictionGenerationacross reconnects.packages/client/test/client/responseCache.test.ts:resetForReconnectunit test to verify that_evictionGenerationis emptied and that the next capture advances past the pre-reset counter.private/publicscopes ×changeServer(false/true) ×ordering(none,capture-write-stale,capture-stale-write,write-recapture-stale,concurrent-post-reset,evicted-pre-reset-and-post-reset)..changeset/reject-pre-reset-cache-writes.md:@modelcontextprotocol/client.Verification
pnpm --filter @modelcontextprotocol/client test(1,100 tests passed)pnpm --filter @modelcontextprotocol/client run typecheckpnpm --filter @modelcontextprotocol/client run lint🤖 Generated with Claude Code