From 1c64fe3eff48679c6880f42d2f0a4af46686ade7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:34:39 +0800 Subject: [PATCH] fix(session): invalidate snapshots before metadata write dispatch Stop cached metadata replay before a provider write starts, including while its reply is pending or fails after applying the mutation. Keep snapshot import and export disabled until the target is reopened. Validate failed replies, conflicting provider identity, and overlapping reads through the shared session boundary. --- docs/cli.md | 4 + src/application/binary/BinarySession.ts | 5 +- .../binary/BinarySessionRecords.ts | 8 +- .../analysis-sessions/binarySession.test.ts | 111 +++++++++++++++++- 4 files changed, 118 insertions(+), 10 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 16afe0f56..48e061c66 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -150,6 +150,10 @@ an exact result when the target bytes, operation, parameters, provider and settings match. Mutations and cursor-dependent calls are excluded from the cache. Snapshot files are local and use owner-only permissions. +Dispatching a metadata write invalidates the active session's snapshot cache, +even if its reply fails: the provider may already have applied the write. +Close and reopen the target before importing or saving another snapshot. + Snapshots retain eligible target-scoped question histories in full, including mutation Evidence recorded while another target was active. Observations and related questions must still belong to the saved target. If those dependencies diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index da8d8e679..9eff0e0b8 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -588,6 +588,9 @@ export class BinarySession if (!prepared.ok) return prepared; const { active, capability, profile, cacheable, cached } = prepared.value; if (cached !== undefined) return ok(cached); + // A failed reply cannot prove that a dispatched metadata write rolled back. + // Stop replay before dispatch, including reads overlapping the write. + if (capability.effects.mutatesArtifact) this.invalidateSnapshot(); const call = active.client.execute(name, arguments_, options); const result = await call; const profiled = bindExecutionTarget( @@ -645,8 +648,6 @@ export class BinarySession parameters: arguments_, execution: execution.value, }); - } else if (profiled.ok && capability.effects.mutatesArtifact) { - this.invalidateSnapshot(); } return execution; } diff --git a/src/application/binary/BinarySessionRecords.ts b/src/application/binary/BinarySessionRecords.ts index 182d5892f..cfa0fa3b2 100644 --- a/src/application/binary/BinarySessionRecords.ts +++ b/src/application/binary/BinarySessionRecords.ts @@ -144,9 +144,9 @@ export abstract class BinarySessionRecords { if (this.#snapshotInvalidated) return err( new EvidenceIntegrityError( - "Analysis snapshots are unavailable after analysis metadata mutations", + "Analysis snapshots are unavailable because analysis metadata may have changed", { - userMessage: `Analysis snapshots are unavailable after analysis metadata mutations. ${SNAPSHOT_MUTATION_RECOVERY}`, + userMessage: `Analysis snapshots are unavailable because analysis metadata may have changed. ${SNAPSHOT_MUTATION_RECOVERY}`, }, ), ); @@ -177,9 +177,9 @@ export abstract class BinarySessionRecords { if (active !== undefined && this.#snapshotInvalidated) return err( new EvidenceIntegrityError( - "Analysis snapshots cannot be imported after analysis metadata mutations", + "Analysis snapshots cannot be imported because analysis metadata may have changed", { - userMessage: `Analysis snapshots cannot be imported after analysis metadata mutations. ${SNAPSHOT_MUTATION_RECOVERY}`, + userMessage: `Analysis snapshots cannot be imported because analysis metadata may have changed. ${SNAPSHOT_MUTATION_RECOVERY}`, }, ), ); diff --git a/tests/composition/analysis-sessions/binarySession.test.ts b/tests/composition/analysis-sessions/binarySession.test.ts index 302d51d50..27e591a17 100644 --- a/tests/composition/analysis-sessions/binarySession.test.ts +++ b/tests/composition/analysis-sessions/binarySession.test.ts @@ -1,6 +1,7 @@ -import type { - AnalysisClient, - AnalysisProvider, +import { + createAnalysisExecution, + type AnalysisClient, + type AnalysisProvider, } from "../../../src/application/AnalysisProvider.js"; import type { BinarySession } from "../../../src/application/binary/BinarySession.js"; import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; @@ -374,7 +375,7 @@ describe("evidence metadata imports and snapshot cache", () => { ok: false, error: { message: expect.stringContaining( - "Analysis snapshots are unavailable after analysis metadata mutations", + "Analysis snapshots are unavailable because analysis metadata may have changed", ), }, }); @@ -408,6 +409,108 @@ describe("evidence metadata imports and snapshot cache", () => { }); }); +describe("snapshot invalidation at mutation dispatch", () => { + it.each(["provider failure", "invalid reply identity"] as const)( + "does not replay metadata after a write followed by %s", + async (failure) => { + const [target] = await createBinarySessionTargets(); + const provider = createCacheProvider([]); + let name = "before"; + provider.createClient = () => ({ + execute: (operation) => { + if (operation === "health") return Promise.resolve(ok(null)); + if (operation === "address_name") return Promise.resolve(ok(name)); + if (operation !== "set_address_name") + throw new Error(`Unexpected operation: ${operation}`); + name = "after"; + return Promise.resolve( + failure === "provider failure" + ? err(new ProviderAdapterError("fixture", operation)) + : resultOk( + createAnalysisExecution(true, { + id: "wrong", + name: "Wrong", + version: "1", + }), + ), + ); + }, + close: () => Promise.resolve(resultOk(null)), + }); + const session = createTestBinarySession(provider); + try { + const opened = await session.open(target); + if (!opened.ok) throw opened.error; + const parameters = { address: "0x1000", document: "first" }; + const before = await session.execute("address_name", parameters); + if (!before.ok) throw before.error; + expect(before.value.result).toBe("before"); + const baseline = session.exportAnalysisSnapshot(); + if (!baseline.ok) throw baseline.error; + + const written = await session.execute("set_address_name", { + ...parameters, + name: "after", + }); + expect(written).toMatchObject({ ok: false }); + const after = await session.execute("address_name", parameters); + if (!after.ok) throw after.error; + expect(after.value.result).toBe("after"); + expect(session.exportAnalysisSnapshot()).toMatchObject({ ok: false }); + expect(session.importAnalysisSnapshot(baseline.value)).toMatchObject({ + ok: false, + }); + } finally { + expect(await session.close()).toEqual({ ok: true, value: null }); + } + }, + ); + + it("stops replay while a dispatched write is still awaiting its reply", async () => { + const [target] = await createBinarySessionTargets(); + const provider = createCacheProvider([]); + const started = createDeferred(); + const reply = createDeferred>(); + let name = "before"; + provider.createClient = () => ({ + execute: (operation) => { + if (operation === "health") return Promise.resolve(ok(null)); + if (operation === "address_name") return Promise.resolve(ok(name)); + if (operation !== "set_address_name") + throw new Error(`Unexpected operation: ${operation}`); + name = "after"; + started.resolve(); + return reply.promise; + }, + close: () => Promise.resolve(resultOk(null)), + }); + const session = createTestBinarySession(provider); + try { + const opened = await session.open(target); + if (!opened.ok) throw opened.error; + const parameters = { address: "0x1000", document: "first" }; + const before = await session.execute("address_name", parameters); + if (!before.ok) throw before.error; + const writing = session.execute("set_address_name", { + ...parameters, + name: "after", + }); + await started.promise; + try { + const during = await session.execute("address_name", parameters); + if (!during.ok) throw during.error; + expect(during.value.result).toBe("after"); + expect(session.exportAnalysisSnapshot()).toMatchObject({ ok: false }); + } finally { + reply.resolve(ok(true)); + expect(await writing).toMatchObject({ ok: true }); + } + } finally { + expect(await session.close()).toEqual({ ok: true, value: null }); + } + }); +}); + describe("replay of exact immutable calls", () => { it("replays exact immutable calls from a matching provider-neutral snapshot", async () => { const [first, second] = await createBinarySessionTargets();