[GH-ISSUE #3334] [Bug] Snapshot opens are overtaken by later MCP reads and closes #1206

Closed
opened 2026-10-15 18:31:59 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 14, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/3334

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

MCP or agent integration

Observed behavior

Adding snapshot_path to open_binary changes the ordering of concurrent MCP requests on the same connection. A later current_document read returns Evidence for the previous target, and a later successful close_binary leaves the earlier snapshot target open after both requests settle. The equivalent requests without a snapshot preserve dispatch order.

The MCP reproduction uses a local injected provider with a concrete profile and document identity, the compiled production MCP adapter, and the compiled production binary session. All tool calls succeed and the command exits successfully. No real analysis engine runs.

Reproduction

  1. Create a clean checkout at the tested source baseline and install its locked dependencies and built runtime:
git clone https://github.com/morluto/rea.git rea-snapshot-order-repro
cd rea-snapshot-order-repro
git checkout 8dde6370d0809ad04cda3342d4e6f290f48aa063
npm ci
npm run build:cached
  1. Run this command from the checkout root. It writes both local database fixtures, creates the snapshot through close_binary, and supplies the complete local provider inline:
REA_REPRO_DIR="$(mktemp -d)"
mkdir -p "$REA_REPRO_DIR/home"
env HOME="$REA_REPRO_DIR/home" REA_REPRO_DIR="$REA_REPRO_DIR" node --input-type=module <<'JS'
import assert from 'node:assert/strict';
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve, basename } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';

const checkout = process.cwd();
const root = process.env.REA_REPRO_DIR;
const load = (path) => import(pathToFileURL(resolve(checkout, path)).href);
const require = createRequire(resolve(checkout, 'package.json'));
const { Client, InMemoryTransport } = await import(pathToFileURL(require.resolve('@modelcontextprotocol/client')).href);
const { BinarySession } = await load('dist/application/binary/BinarySession.js');
const { AnalysisProviderRegistry } = await load('dist/application/binary/AnalysisProviderRegistry.js');
const { SessionProviderRouter } = await load('dist/application/binary/SessionProviderRouter.js');
const { createServer } = await load('dist/server/createServer.js');
const { createAnalysisProfile } = await load('dist/domain/analysisProfile.js');
const { createAnalysisExecution } = await load('dist/application/AnalysisProvider.js');
const { ok } = await load('dist/domain/result.js');

await mkdir(root, { recursive: true });
const first = resolve(root, 'first.hop');
const second = resolve(root, 'second.hop');
const snapshotPath = resolve(root, 'snapshot.json');
await writeFile(first, 'one');
await writeFile(second, 'two');
const identity = { id: 'fixture', name: 'Fixture analysis provider', version: '1' };
const profile = createAnalysisProfile(identity, { fixture: true });
const provider = {
  identity: () => identity,
  capabilities: () => [{
    provider: identity, operation: 'current_document', available: true,
    reason: null, cachePolicy: 'live', limitations: [],
    effects: {
      mutatesArtifact: false, launchesProcess: false, mayShowUi: false,
      mayAccessNetwork: false, mayWriteFilesystem: false,
      changesPermissions: false, requiresRoot: false,
    },
  }],
  resolveAnalysisProfile: async () => ok({ profile }),
  inspectAvailability: () => ({ status: 'available', code: null, reason: null, diagnostics: {} }),
  inspectTargetSupport: () => ({ status: 'supported', code: null, reason: null, diagnostics: {} }),
  createClient: (target) => ({
    execute: async (name) => ok(createAnalysisExecution(name === 'current_document' ? target.path : null, identity)),
    close: async () => ok(null),
  }),
};
const session = new BinarySession(SessionProviderRouter.selectable(new AnalysisProviderRegistry([provider]), []));
const server = createServer({ kind: 'session', session }, { environment: {} });
const client = new Client({ name: 'snapshot-order-reproduction', version: '1' });
const [ct, st] = InMemoryTransport.createLinkedPair();
await server.connect(st);
await client.connect(ct);
const call = async (name, args = {}) => {
  const reply = await client.callTool({ name, arguments: args });
  assert.notEqual(reply.isError, true, `${name} failed: ${JSON.stringify(reply.content)}`);
  return reply.structuredContent;
};
try {
  await call('open_binary', { path: first });
  await call('close_binary', { snapshot_path: snapshotPath, overwrite: true });

  await call('open_binary', { path: second });
  const controlOpen = call('open_binary', { path: first });
  const controlQuery = call('current_document');
  const controlRead = await controlQuery;
  await controlOpen;
  console.log('no-snapshot open/read:', basename(controlRead.normalized_result));

  await call('open_binary', { path: second });
  const snapshotOpen = call('open_binary', { path: first, snapshot_path: snapshotPath });
  const snapshotQuery = call('current_document');
  const snapshotRead = await snapshotQuery;
  await snapshotOpen;
  console.log('snapshot open/read:', basename(snapshotRead.normalized_result));

  await call('open_binary', { path: second });
  const controlOpening = call('open_binary', { path: first });
  const controlClosing = call('close_binary');
  await Promise.all([controlOpening, controlClosing]);
  const controlStatus = await call('binary_session');
  console.log('no-snapshot open/close: open =', controlStatus.result.open);

  await call('open_binary', { path: second });
  const snapshotOpening = call('open_binary', { path: first, snapshot_path: snapshotPath });
  const snapshotClosing = call('close_binary');
  await Promise.all([snapshotOpening, snapshotClosing]);
  const snapshotStatus = await call('binary_session');
  console.log('snapshot open/close: open =', snapshotStatus.result.open, ', target =', basename(snapshotStatus.result.path));
} finally {
  await client.close();
  await server.close();
  await session.close();
}
JS
rm -rf "$REA_REPRO_DIR"
  1. Compare the printed open/read and open/close sequences. Each pair includes a no-snapshot control, and the command waits for both concurrent requests before inspecting the resulting state. The snapshot read returns the previous target's document. In the final case, the later close completes but the earlier snapshot open subsequently leaves the first target active.

Expected behavior

Snapshot loading must preserve the shared session's ordering of queued transitions and reads through the shared queue at src/application/binary/BinarySession.ts:854. A read dispatched after opening the first target should use the first target. A close dispatched after that open should leave the session closed once both requests settle, with or without snapshot_path.

tests/composition/analysis-sessions/binarySession.test.ts:1136, "orders calls between queued transitions without deadlocking," dispatches opens, reads, and a close without awaiting each request and requires the final session to be closed. AGENTS.md:5 requires preserving session semantics through both CLI and MCP adapters. tests/boundary/mcp/sessionMcp.test.ts:51 also pins that an MCP close blocks later calls.

Impact and scope

Impact level: P2, supported. Same-connection concurrent requests can receive Evidence for the previous target or retain an active target after a later successful close. The local provider fixtures establish the adapter ordering failure; they do not establish real engine startup or cleanup consequences.

Workaround status: available. Await the lifecycle request before dispatching dependent reads or another lifecycle request. The serial setup calls complete successfully; the defect appears when a snapshot open and its following operation overlap.

REA version

6.4.0 (main at 8dde6370d0)

Runtime and platform

Node.js v26.10.0 on macOS 27.0.1. Architecture was not recorded. Dependencies and the compiled runtime were already present in the observed run.

Target and artifact context

Two local database fixtures, first.hop containing one and second.hop containing two, plus snapshot.json created by a successful close_binary of the first fixture. The local provider supplies a concrete analysis profile and returns each target's document identity. The MCP client and server use linked in-memory transports. No real analysis engine runs.

Interface

MCP

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

no-snapshot open/read: first.hop
snapshot open/read: second.hop
no-snapshot open/close: open = false
snapshot open/close: open = true , target = first.hop

The output was observed through the executed MCP open/read and open/close sequences. Evidence provenance: observed. Local verification: reproduced. Reproduction completeness: complete.

Typed outcome or recovery expected

Run the same paired sequences and require first.hop from both open/read cases and open = false from both open/close cases. Preserve snapshot validation and failed-open session preservation. Keep both no-snapshot sequences as discriminating controls. The check must still use concurrent calls; awaiting the open before dispatching the read or close avoids the defect without establishing restored ordering.

Analysis or suspected cause

Source-derived reasoning: src/server/registerSessionTools.ts:195 awaits snapshot reading before reaching session.open at src/server/registerSessionTools.ts:205, while the shared transition queue is entered synchronously in src/application/binary/BinarySession.ts:854. This asynchronous read allows later requests to reach the shared session before the snapshot open reserves its place in that queue. The runtime observations above establish the ordering failure; this source reasoning explains the suspected cause.

Additional context

  • Related #1719 concerns same-target reopening without a live provider client; the present failure occurs between different live fixture targets because requests overtake a snapshot open.
  • Related #1746 concerns committing Evidence after import cancellation; the present failure uses no cancellation and concerns lifecycle request ordering.
  • Related #1190 concerns a successful close leaving a runtime directory after failed filesystem cleanup; the present failure leaves the session logically open because an earlier snapshot open executes after the later close.
  • Related #2975 concerns Hopper exit blocking subsequent opens with cleanup_incomplete; the present failure requires no provider exit or cleanup error, and all requests succeed.
Originally created by @coygeek on GitHub (Oct 14, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3334 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area MCP or agent integration ### Observed behavior Adding `snapshot_path` to `open_binary` changes the ordering of concurrent MCP requests on the same connection. A later `current_document` read returns Evidence for the previous target, and a later successful `close_binary` leaves the earlier snapshot target open after both requests settle. The equivalent requests without a snapshot preserve dispatch order. The MCP reproduction uses a local injected provider with a concrete profile and document identity, the compiled production MCP adapter, and the compiled production binary session. All tool calls succeed and the command exits successfully. No real analysis engine runs. ### Reproduction 1. Create a clean checkout at the tested source baseline and install its locked dependencies and built runtime: ```sh git clone https://github.com/morluto/rea.git rea-snapshot-order-repro cd rea-snapshot-order-repro git checkout 8dde6370d0809ad04cda3342d4e6f290f48aa063 npm ci npm run build:cached ``` 2. Run this command from the checkout root. It writes both local database fixtures, creates the snapshot through `close_binary`, and supplies the complete local provider inline: ```sh REA_REPRO_DIR="$(mktemp -d)" mkdir -p "$REA_REPRO_DIR/home" env HOME="$REA_REPRO_DIR/home" REA_REPRO_DIR="$REA_REPRO_DIR" node --input-type=module <<'JS' import assert from 'node:assert/strict'; import { mkdir, writeFile } from 'node:fs/promises'; import { resolve, basename } from 'node:path'; import { pathToFileURL } from 'node:url'; import { createRequire } from 'node:module'; const checkout = process.cwd(); const root = process.env.REA_REPRO_DIR; const load = (path) => import(pathToFileURL(resolve(checkout, path)).href); const require = createRequire(resolve(checkout, 'package.json')); const { Client, InMemoryTransport } = await import(pathToFileURL(require.resolve('@modelcontextprotocol/client')).href); const { BinarySession } = await load('dist/application/binary/BinarySession.js'); const { AnalysisProviderRegistry } = await load('dist/application/binary/AnalysisProviderRegistry.js'); const { SessionProviderRouter } = await load('dist/application/binary/SessionProviderRouter.js'); const { createServer } = await load('dist/server/createServer.js'); const { createAnalysisProfile } = await load('dist/domain/analysisProfile.js'); const { createAnalysisExecution } = await load('dist/application/AnalysisProvider.js'); const { ok } = await load('dist/domain/result.js'); await mkdir(root, { recursive: true }); const first = resolve(root, 'first.hop'); const second = resolve(root, 'second.hop'); const snapshotPath = resolve(root, 'snapshot.json'); await writeFile(first, 'one'); await writeFile(second, 'two'); const identity = { id: 'fixture', name: 'Fixture analysis provider', version: '1' }; const profile = createAnalysisProfile(identity, { fixture: true }); const provider = { identity: () => identity, capabilities: () => [{ provider: identity, operation: 'current_document', available: true, reason: null, cachePolicy: 'live', limitations: [], effects: { mutatesArtifact: false, launchesProcess: false, mayShowUi: false, mayAccessNetwork: false, mayWriteFilesystem: false, changesPermissions: false, requiresRoot: false, }, }], resolveAnalysisProfile: async () => ok({ profile }), inspectAvailability: () => ({ status: 'available', code: null, reason: null, diagnostics: {} }), inspectTargetSupport: () => ({ status: 'supported', code: null, reason: null, diagnostics: {} }), createClient: (target) => ({ execute: async (name) => ok(createAnalysisExecution(name === 'current_document' ? target.path : null, identity)), close: async () => ok(null), }), }; const session = new BinarySession(SessionProviderRouter.selectable(new AnalysisProviderRegistry([provider]), [])); const server = createServer({ kind: 'session', session }, { environment: {} }); const client = new Client({ name: 'snapshot-order-reproduction', version: '1' }); const [ct, st] = InMemoryTransport.createLinkedPair(); await server.connect(st); await client.connect(ct); const call = async (name, args = {}) => { const reply = await client.callTool({ name, arguments: args }); assert.notEqual(reply.isError, true, `${name} failed: ${JSON.stringify(reply.content)}`); return reply.structuredContent; }; try { await call('open_binary', { path: first }); await call('close_binary', { snapshot_path: snapshotPath, overwrite: true }); await call('open_binary', { path: second }); const controlOpen = call('open_binary', { path: first }); const controlQuery = call('current_document'); const controlRead = await controlQuery; await controlOpen; console.log('no-snapshot open/read:', basename(controlRead.normalized_result)); await call('open_binary', { path: second }); const snapshotOpen = call('open_binary', { path: first, snapshot_path: snapshotPath }); const snapshotQuery = call('current_document'); const snapshotRead = await snapshotQuery; await snapshotOpen; console.log('snapshot open/read:', basename(snapshotRead.normalized_result)); await call('open_binary', { path: second }); const controlOpening = call('open_binary', { path: first }); const controlClosing = call('close_binary'); await Promise.all([controlOpening, controlClosing]); const controlStatus = await call('binary_session'); console.log('no-snapshot open/close: open =', controlStatus.result.open); await call('open_binary', { path: second }); const snapshotOpening = call('open_binary', { path: first, snapshot_path: snapshotPath }); const snapshotClosing = call('close_binary'); await Promise.all([snapshotOpening, snapshotClosing]); const snapshotStatus = await call('binary_session'); console.log('snapshot open/close: open =', snapshotStatus.result.open, ', target =', basename(snapshotStatus.result.path)); } finally { await client.close(); await server.close(); await session.close(); } JS rm -rf "$REA_REPRO_DIR" ``` 3. Compare the printed open/read and open/close sequences. Each pair includes a no-snapshot control, and the command waits for both concurrent requests before inspecting the resulting state. The snapshot read returns the previous target's document. In the final case, the later close completes but the earlier snapshot open subsequently leaves the first target active. ### Expected behavior Snapshot loading must preserve the shared session's ordering of queued transitions and reads through the shared queue at `src/application/binary/BinarySession.ts:854`. A read dispatched after opening the first target should use the first target. A close dispatched after that open should leave the session closed once both requests settle, with or without `snapshot_path`. `tests/composition/analysis-sessions/binarySession.test.ts:1136`, "orders calls between queued transitions without deadlocking," dispatches opens, reads, and a close without awaiting each request and requires the final session to be closed. `AGENTS.md:5` requires preserving session semantics through both CLI and MCP adapters. `tests/boundary/mcp/sessionMcp.test.ts:51` also pins that an MCP close blocks later calls. ### Impact and scope Impact level: P2, supported. Same-connection concurrent requests can receive Evidence for the previous target or retain an active target after a later successful close. The local provider fixtures establish the adapter ordering failure; they do not establish real engine startup or cleanup consequences. Workaround status: available. Await the lifecycle request before dispatching dependent reads or another lifecycle request. The serial setup calls complete successfully; the defect appears when a snapshot open and its following operation overlap. ### REA version 6.4.0 (main at 8dde6370d0809ad04cda3342d4e6f290f48aa063) ### Runtime and platform Node.js v26.10.0 on macOS 27.0.1. Architecture was not recorded. Dependencies and the compiled runtime were already present in the observed run. ### Target and artifact context Two local database fixtures, `first.hop` containing `one` and `second.hop` containing `two`, plus `snapshot.json` created by a successful `close_binary` of the first fixture. The local provider supplies a concrete analysis profile and returns each target's document identity. The MCP client and server use linked in-memory transports. No real analysis engine runs. ### Interface MCP ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence ```text no-snapshot open/read: first.hop snapshot open/read: second.hop no-snapshot open/close: open = false snapshot open/close: open = true , target = first.hop ``` The output was observed through the executed MCP open/read and open/close sequences. Evidence provenance: observed. Local verification: reproduced. Reproduction completeness: complete. ### Typed outcome or recovery expected Run the same paired sequences and require `first.hop` from both open/read cases and `open = false` from both open/close cases. Preserve snapshot validation and failed-open session preservation. Keep both no-snapshot sequences as discriminating controls. The check must still use concurrent calls; awaiting the open before dispatching the read or close avoids the defect without establishing restored ordering. ### Analysis or suspected cause Source-derived reasoning: `src/server/registerSessionTools.ts:195` awaits snapshot reading before reaching `session.open` at `src/server/registerSessionTools.ts:205`, while the shared transition queue is entered synchronously in `src/application/binary/BinarySession.ts:854`. This asynchronous read allows later requests to reach the shared session before the snapshot open reserves its place in that queue. The runtime observations above establish the ordering failure; this source reasoning explains the suspected cause. ### Additional context - Related [#1719](https://github.com/morluto/rea/issues/1719) concerns same-target reopening without a live provider client; the present failure occurs between different live fixture targets because requests overtake a snapshot open. - Related [#1746](https://github.com/morluto/rea/issues/1746) concerns committing Evidence after import cancellation; the present failure uses no cancellation and concerns lifecycle request ordering. - Related [#1190](https://github.com/morluto/rea/issues/1190) concerns a successful close leaving a runtime directory after failed filesystem cleanup; the present failure leaves the session logically open because an earlier snapshot open executes after the later close. - Related [#2975](https://github.com/morluto/rea/issues/2975) concerns Hopper exit blocking subsequent opens with `cleanup_incomplete`; the present failure requires no provider exit or cleanup error, and all requests succeed.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#1206
No description provided.