Repository navigation
Description
Title: client: delayed metadata and family reads resurrect deleted sessions in the client cache
Summary
A successful metadata or family response started before session.deleted can restore the deleted record after the event has already removed it from the client data store.
There are two reproducible sequences: a session's own delayed metadata read, and a parent's delayed family read containing a concurrently deleted child. In the family case, an immediate subsequent session.sync(child) makes no new GET request, because publication of the stale family snapshot also marks the child synchronized.
This is a client projection/cache consistency issue. It does not restore a session in authoritative server storage, and no server-side resurrection or durable data loss is claimed.
Environment
- Windows; Bun 1.4.2.
- Dynamically reproduced against a custom checkout at
72d0dce8ea437ea061e0680110fa19a81aa118fd. - Compared official references:
63c1a27af5a391d9caa55b82694f496911e1859band latest-checked60a5a6ca7ce176b04cd2321ffd1819b1ea8e6006. - The metadata/family sync implementation is identical at all three revisions. The official revisions were source-compared, not dynamically executed.
- The fixture calls real production
createDataand the generated Promise client, with injected in-memory HTTP responses and an event bus. It does not copy the reducer or access a real service, database, model, or user session.
Reproduction
Use a Solid createRoot, instantiate OpenCode.make with an injected fetch implementation, and pass that client and an in-memory event emitter to the production createData. This follows the fixture pattern in packages/client/test/solid-data.test.ts.
Deliver correctly shaped synthetic durable session.deleted events through the emitter's listen handlers. All session labels below refer to fixture-only records. Use deferred promises to control response completion, not sleeps or network timing.
A. Delayed metadata response
- Call
data.session.remember(A)with a valid fixtureSessionInfo. - Call
data.session.invalidate(A.id), then startdata.session.sync(A.id). - Have the mocked
GET /api/session/<A>signal that it has started, but withhold its pre-deletion snapshot response. - Emit
session.deletedfor A. - Assert that
data.session.get(A.id)is absent anddata.session.family(A.id)is empty. These checks pass before releasing the response. - Resolve the deferred request with
Response.json({ data: A })and await the actual sync promise. - Assert that A is still absent. This fails: A is present again and its family is
[A].
B. Delayed parent-family response
- Remember a root R and child C, where
C.parentID === R.id. - Start
data.session.sync(R.id, { children: true }). - Return R normally from
GET /api/session/<R>. Withhold the child-list response fromGET /api/session?limit=200&order=desc&parentID=<R>, retaining a snapshot containing C. - Once the child-list request has started, emit
session.deletedfor C. - Check that C is absent from
getand from R's family. - Release
Response.json({ data: [C], cursor: {} })and await the root-family sync. - Observe that C has returned to both the metadata store and R's family.
- Call and await
data.session.sync(C.id)while counting injected fetch calls. The count remains two total requests: the original root GET and child-list GET. There is no child GET to check the resurrected record.
Expected Behavior
- An observed deletion takes precedence over an earlier metadata or child-list snapshot.
- The deleted record remains absent from client session information and family membership after the earlier response completes.
- A stale family snapshot must not silently establish a deleted child as a successfully synchronized cache entry.
Actual Behavior
| Sequence | Immediately after deletion | After the older response completes |
|---|---|---|
| Own metadata read | A absent; family empty | A present; family [A] |
| Parent family read | C absent; R's family excludes C | C present; R's family [R, C] |
| Child sync after family resurrection | Two requests already made | Still two requests; no child GET |
Both deletion-ordering assertions fail against the tested production data layer.
Additional Context
Source locations are repository-relative:
packages/client/src/solid/data.ts:607–635in the tested custom checkout:removeSessioninvalidates the session/family keys and removes metadata and family membership.packages/client/src/solid/data.ts:687–689in that checkout:session.deleteddispatches toremoveSession.packages/client/src/solid/data.ts:1662–1683in that checkout, corresponding to:1656–1677in both official references: the sync callback unconditionally publishes[info, ...children], callssync.completefor each member, and registers family membership.
Deleting C invalidates C's own keys, not the pending family read keyed by R. Consequently, checking only whether R's own read remains current would not address sequence B.
Controls in the same isolated run:
- Completing a metadata read before delivering deletion leaves the record absent afterward.
- A delayed transcript read cannot republish after deletion on the tested custom checkout, which has additional transcript-discard protection. This control is not claimed as an official-runtime result.
- Across this and the companion retention reproduction, the final run recorded 3 passing controls and 3 failing product assertions, including these two failures. Exit status was 1, not a passing gate. All fixtures finished with zero pending responses, zero event listeners, and zero captured request errors; no fallback fetch occurred.
This report concerns the shared client data owner, not Home's separate query-cache merge behavior. The Home-only candidate related to #49419 is not being reported here. No full rendered-Home reproduction is claimed.
Suggested solution — not implemented
Maintainer decision requested: make deletion precedence part of metadata/family snapshot publication, including deletion of members while an ancestor's listing is in flight. A per-member generation or equivalent bounded reconciliation mechanism may be appropriate; ordinary refresh invalidation alone does not revoke the old response's ability to write.
Preserve the valid normal-order case and avoid treating every refresh as a deletion. Retain both sequences as focused regression coverage, particularly the family case and its subsequent no-GET cache hit. No implementation or production behavior change is included in this report.
Activity
opencode-agent commentedon Oct 18, 2026
Thanks for the detailed report. I reproduced both sequences on the latest
dev(60a5a6c) using the realcreateDataand the generated client, with a mocked fetch and an in-memory event bus:session.sync(A)and deliveredsession.deletedfor A while the GET was still pending, and A was gone. Once the earlier response finished,get(A)returned A again andfamily(A)was[A].sync(R, { children: true })and deleted child C while the child-list request was still pending. Once the list finished, C was back andfamily(R)was[R, C]. A followingsync(C)made no new request, with only the two original requests in total.The test file below reproduces both cases. Copy it into
packages/client/test/and runbun testthere.Reproduction script (session-delete-race.test.ts)
Run it:
bun session-delete-race.test.ts