Skip to content

client: delayed metadata and family reads resurrect deleted sessions in the client cache #56736

Description

@521ox

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: 63c1a27af5a391d9caa55b82694f496911e1859b and latest-checked 60a5a6ca7ce176b04cd2321ffd1819b1ea8e6006.
  • 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 createData and 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

  1. Call data.session.remember(A) with a valid fixture SessionInfo.
  2. Call data.session.invalidate(A.id), then start data.session.sync(A.id).
  3. Have the mocked GET /api/session/<A> signal that it has started, but withhold its pre-deletion snapshot response.
  4. Emit session.deleted for A.
  5. Assert that data.session.get(A.id) is absent and data.session.family(A.id) is empty. These checks pass before releasing the response.
  6. Resolve the deferred request with Response.json({ data: A }) and await the actual sync promise.
  7. Assert that A is still absent. This fails: A is present again and its family is [A].

B. Delayed parent-family response

  1. Remember a root R and child C, where C.parentID === R.id.
  2. Start data.session.sync(R.id, { children: true }).
  3. Return R normally from GET /api/session/<R>. Withhold the child-list response from GET /api/session?limit=200&order=desc&parentID=<R>, retaining a snapshot containing C.
  4. Once the child-list request has started, emit session.deleted for C.
  5. Check that C is absent from get and from R's family.
  6. Release Response.json({ data: [C], cursor: {} }) and await the root-family sync.
  7. Observe that C has returned to both the metadata store and R's family.
  8. 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–635 in the tested custom checkout: removeSession invalidates the session/family keys and removes metadata and family membership.
  • packages/client/src/solid/data.ts:687–689 in that checkout: session.deleted dispatches to removeSession.
  • packages/client/src/solid/data.ts:1662–1683 in that checkout, corresponding to :1656–1677 in both official references: the sync callback unconditionally publishes [info, ...children], calls sync.complete for 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

  1. opencode-agent commented on Oct 18, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I reproduced both sequences on the latest dev (60a5a6c) using the real createData and the generated client, with a mocked fetch and an in-memory event bus:

    • Own metadata read: I started session.sync(A) and delivered session.deleted for A while the GET was still pending, and A was gone. Once the earlier response finished, get(A) returned A again and family(A) was [A].
    • Parent family read: I started sync(R, { children: true }) and deleted child C while the child-list request was still pending. Once the list finished, C was back and family(R) was [R, C]. A following sync(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 run bun test there.

    Reproduction script (session-delete-race.test.ts)
    // Run from /workspace/repo/packages/client:  bun test <path-to-this-file>
    // (copy into packages/client/test/ so relative imports resolve)
    import { expect, test } from "bun:test"
    import { createRoot } from "solid-js"
    import { createData, type CreateDataInput } from "../src/solid"
    import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
    
    const info = (id: string, parentID?: string): SessionInfo => ({
      id,
      projectID: "project",
      ...(parentID ? { parentID } : {}),
      cost: 0,
      tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
      outcome: "succeeded",
      time: { created: 0, updated: 0, idle: 0, viewed: 0 },
      location: { directory: "/project" },
    })
    
    function setup(handler: (url: URL) => Promise<Response>) {
      const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
      const requests: string[] = []
      const api = OpenCode.make({
        baseUrl: "http://opencode.local",
        fetch: async (input, init) => {
          const url = new URL((input instanceof Request ? input : new Request(input, init)).url)
          requests.push(`${url.pathname}?${url.searchParams}`)
          return handler(url)
        },
      })
      const root = createRoot((dispose) => ({
        data: createData({
          api: () => api,
          directory: "/project",
          event: {
            on: () => () => {},
            listen(h) {
              listeners.add(h)
              return () => listeners.delete(h)
            },
          },
        }),
        dispose,
      }))
      const deleted = (sessionID: string) => {
        const event = {
          id: `evt_del_${sessionID}`,
          created: 1,
          type: "session.deleted",
          durable: { aggregateID: sessionID, seq: 1, version: 1 },
          data: { sessionID },
        } as unknown as OpenCodeEvent
        listeners.forEach((l) => l({ name: event.type, details: event }))
      }
      return { ...root, deleted, requests }
    }
    
    test("A: delayed metadata read must not resurrect a deleted session", async () => {
      const A = info("ses_A")
      const started = Promise.withResolvers<void>()
      const release = Promise.withResolvers<void>()
      const s = setup(async (url) => {
        if (url.pathname === "/api/session/ses_A") {
          started.resolve()
          await release.promise
          return Response.json({ data: A })
        }
        throw new Error("unexpected " + url)
      })
      try {
        s.data.session.remember(A)
        s.data.session.invalidate(A.id)
        const pending = s.data.session.sync(A.id)
        await started.promise
        s.deleted(A.id)
        expect(s.data.session.get(A.id)).toBeUndefined()
        release.resolve()
        await pending
        console.log("A after delayed response:", s.data.session.get(A.id)?.id, s.data.session.family(A.id).map((x: any) => x.id ?? x))
        expect(s.data.session.get(A.id)).toBeUndefined()
      } finally {
        release.resolve()
        s.dispose()
      }
    })
    
    test("B: delayed family read must not resurrect a deleted child", async () => {
      const R = info("ses_R")
      const C = info("ses_C", "ses_R")
      const started = Promise.withResolvers<void>()
      const release = Promise.withResolvers<void>()
      const s = setup(async (url) => {
        if (url.pathname === "/api/session/ses_R") return Response.json({ data: R })
        if (url.pathname === "/api/session" && url.searchParams.get("parentID") === "ses_R") {
          started.resolve()
          await release.promise
          return Response.json({ data: [C], cursor: {} })
        }
        if (url.pathname === "/api/session/ses_C") return new Response("not found", { status: 404 })
        throw new Error("unexpected " + url)
      })
      try {
        s.data.session.remember(R)
        s.data.session.remember(C)
        const pending = s.data.session.sync(R.id, { children: true })
        await started.promise
        s.deleted(C.id)
        expect(s.data.session.get(C.id)).toBeUndefined()
        release.resolve()
        await pending
        console.log("C after delayed family:", s.data.session.get(C.id)?.id, "family(R):", s.data.session.family(R.id).map((x: any) => x.id ?? x))
        await s.data.session.sync(C.id).catch(() => {})
        console.log("requests:", s.requests)
        expect(s.data.session.get(C.id)).toBeUndefined()
      } finally {
        release.resolve()
        s.dispose()
      }
    })

    Run it: bun session-delete-race.test.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions