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();