Repository navigation
[v2] Response cache accepts pre-reset generation-0 writes after reconnect #3075
Description
What happened?
Follow-up to the write-side concern raised in #3072 (comment).
At the cache coordinator boundary, a response whose generation was captured before resetForReconnect() can still be written afterward. Reset clears _evictionGeneration; write() treats the missing key as generation 0 via ?? 0, so an old generation-0 token passes. It then derives the storage partition from the current server identity.
Consequences reproduced with a user-supplied store:
- Reconnect to the same identity: the old response can overwrite a fresh response already cached on the new connection.
- Reconnect to a different identity: the old response is stored in the new server identity's partition.
- Both private and public scope are affected.
I ran a deterministic coordinator-level matrix: private/public × same/different server identity × with/without a fresh write before the old write. All 8 cases accept the stale write on both pinned main 9adb8d5 and #3072 head 15e9641. This establishes the coordinator behavior; it is not an end-to-end transport reconnect reproduction. Client._listAllPages() and _readResource() capture a generation before awaiting their fetch and pass it to write() afterward.
Related scope:
- fix(client): discard cache reads invalidated while pending #3072 guards pending reads across reset; it deliberately leaves the existing write path unchanged. This behavior predates that PR.
- fix(client): serialize response cache invalidation #2688 addresses an async
store.set()completing after notification-driven invalidation. Its current patch does not bind generation snapshots to a connection, so this reset/token issue is separate.
AI assistance was used to investigate and reproduce this report.
What did you expect?
A generation snapshot captured on an old connection should not authorize a cache write after reset, even when both per-key counters are 0. Fresh writes and intentional reuse of retained store entries should continue to work.
A possible direction is an internal snapshot/token containing both connection and per-key generations, captured before the asynchronous fetch and checked by write(). Capturing a connection generation only when write() begins would miss the demonstrated ordering. Ordering of store mutations already in flight is a separate concern and also needs to be considered alongside #2688.
Code to reproduce
// Run from a repository checkout with dependencies installed:
// pnpm exec tsx repro.ts
// ClientResponseCache is an internal export, not a package-root API.
import assert from 'node:assert/strict';
import { ClientResponseCache, InMemoryResponseCacheStore }
from './packages/client/src/client/responseCache.ts';
const store = new InMemoryResponseCacheStore();
const cache = new ClientResponseCache(store, true, undefined, 'alice');
cache.setServerIdentity('server-a');
const oldGeneration = cache.captureGeneration('tools/list'); // 0
cache.resetForReconnect();
cache.setServerIdentity('server-b');
const freshness = { expiresAt: Date.now() + 60_000, scope: 'private' as const };
const newGeneration = cache.captureGeneration('tools/list'); // also 0
await cache.write('tools/list', { tools: [{ name: 'new' }] }, newGeneration, freshness);
// Model the old response reaching the write boundary only after reset.
await cache.write('tools/list', { tools: [{ name: 'old' }] }, oldGeneration, freshness);
const hit = await cache.read('tools/list');
assert.deepEqual(hit?.value, { tools: [{ name: 'new' }] });
// Fails: actual is { tools: [{ name: 'old' }] }, under server-b's partition.SDK version
main 9adb8d5; also PR #3072 head 15e9641; macOS, Node 22.23.3, pnpm 10.26.1
Area
Client
Activity
fix(client): reject stale cache writes across reconnect (modelcontext…