From cc0ee2edc60dda42d7b7cfb2c08e39b0b736b5e9 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:15:50 +0800 Subject: [PATCH] fix(javascript): honor cancellation before evidence publication (#1098) --- docs/mcp-contracts.md | 9 + .../JavaScriptAnalysisControl.test.ts | 39 +++++ .../javascript/JavaScriptAnalysisControl.ts | 44 +++++ .../JavaScriptApplicationCancellation.test.ts | 75 +++++++++ .../JavaScriptApplicationEvidence.ts | 100 +++++++++-- .../JavaScriptApplicationService.ts | 32 +++- .../JavaScriptArtifactReconstruction.ts | 4 + src/cli/electronCommands.ts | 24 +-- src/cli/javascriptApplicationAnalysis.ts | 9 +- src/domain/canonicalJsonDigestSteps.test.ts | 66 ++++++++ src/domain/canonicalJsonDigestSteps.ts | 156 ++++++++++++++++++ src/domain/evidence.ts | 54 +++++- src/domain/evidenceSteps.test.ts | 117 +++++++++++++ src/domain/immutableJson.ts | 103 +++++++++--- src/domain/immutableJsonSteps.test.ts | 83 ++++++++++ src/domain/jsonValue.ts | 40 ++++- .../javascriptCancellationCli.test.ts | 89 ++++++++++ .../javascriptAnalysisCancellation.test.ts | 110 ++++++++++++ 18 files changed, 1080 insertions(+), 74 deletions(-) create mode 100644 src/application/javascript/JavaScriptAnalysisControl.test.ts create mode 100644 src/application/javascript/JavaScriptAnalysisControl.ts create mode 100644 src/application/javascript/JavaScriptApplicationCancellation.test.ts create mode 100644 src/domain/canonicalJsonDigestSteps.test.ts create mode 100644 src/domain/canonicalJsonDigestSteps.ts create mode 100644 src/domain/evidenceSteps.test.ts create mode 100644 src/domain/immutableJsonSteps.test.ts create mode 100644 tests/acceptance/applications/javascriptCancellationCli.test.ts create mode 100644 tests/boundary/mcp/javascriptAnalysisCancellation.test.ts diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 3d18c2033..1d379e444 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -81,6 +81,15 @@ uses `cleanup_incomplete` and lists only the owned resource kinds that remain. Derived comparisons and reconstruction verification yield before computation and before publication, so cancellation cannot race with successful Evidence. +`analyze_javascript_application` also yields between reconstruction phases and +during complete-result sealing, Evidence JSON validation, and canonical hashing. +Cancellation observed before completion returns `cancelled` and prevents the +provisional result from entering the session ledger; prior Evidence stays usable. +Single-file parsing, graph completion, and full application-result schema +validation still run synchronously, so control messages can wait for those +phases to release the event loop. A rejected client promise alone does not +establish that the server has stopped its work. + CLI calls work without a progress token and translate SIGINT into the same AbortSignal used by providers. Existing controlled-process cleanup and provider shutdown rules still apply; REA never kills a process it cannot prove it owns. diff --git a/src/application/javascript/JavaScriptAnalysisControl.test.ts b/src/application/javascript/JavaScriptAnalysisControl.test.ts new file mode 100644 index 000000000..a15b474e5 --- /dev/null +++ b/src/application/javascript/JavaScriptAnalysisControl.test.ts @@ -0,0 +1,39 @@ +import { setImmediate } from "node:timers/promises"; +import { describe, expect, it } from "vitest"; +import { completeJavaScriptAnalysisSteps } from "./JavaScriptAnalysisControl.js"; + +describe("JavaScript cooperative computation lifecycle", () => { + it("observes scheduler cancellation and closes abandoned work", async () => { + const controller = new AbortController(); + let released = false; + function* work(): Generator { + try { + for (;;) yield; + } finally { + released = true; + } + } + const cancellation = setImmediate().then(() => controller.abort()); + await expect( + completeJavaScriptAnalysisSteps(work(), controller.signal), + ).rejects.toMatchObject({ reason: "cancelled" }); + await cancellation; + expect(released).toBe(true); + }); + + it("does not start pre-cancelled work and preserves completed values", async () => { + const controller = new AbortController(); + controller.abort(); + let started = false; + function* work(): Generator { + started = true; + yield; + return 42; + } + await expect( + completeJavaScriptAnalysisSteps(work(), controller.signal), + ).rejects.toMatchObject({ reason: "cancelled" }); + expect(started).toBe(false); + expect(await completeJavaScriptAnalysisSteps(work())).toBe(42); + }); +}); diff --git a/src/application/javascript/JavaScriptAnalysisControl.ts b/src/application/javascript/JavaScriptAnalysisControl.ts new file mode 100644 index 000000000..d6dbaa0b6 --- /dev/null +++ b/src/application/javascript/JavaScriptAnalysisControl.ts @@ -0,0 +1,44 @@ +import { setImmediate } from "node:timers/promises"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; + +/** Reject cancelled application work before computing or publishing more results. */ +export const assertJavaScriptAnalysisActive = (signal?: AbortSignal): void => { + if (signal?.aborted === true) + throw new ArtifactReaderFailure( + "cancelled", + "JavaScript application analysis cancelled", + ); +}; + +/** Let request control messages run, then recheck the current analysis intent. */ +export const checkpointJavaScriptAnalysis = async ( + signal?: AbortSignal, +): Promise => { + assertJavaScriptAnalysisActive(signal); + await setImmediate(); + assertJavaScriptAnalysisActive(signal); +}; + +/** Drive bounded owned-domain steps while permitting cancellation and control I/O. */ +export const completeJavaScriptAnalysisSteps = async ( + steps: Iterator, + signal?: AbortSignal, +): Promise => { + let deadline = performance.now() + 8; + try { + for (;;) { + assertJavaScriptAnalysisActive(signal); + const step = steps.next(); + if (step.done) { + assertJavaScriptAnalysisActive(signal); + return step.value; + } + if (performance.now() >= deadline) { + await checkpointJavaScriptAnalysis(signal); + deadline = performance.now() + 8; + } + } + } finally { + steps.return?.(); + } +}; diff --git a/src/application/javascript/JavaScriptApplicationCancellation.test.ts b/src/application/javascript/JavaScriptApplicationCancellation.test.ts new file mode 100644 index 000000000..e5160ffe5 --- /dev/null +++ b/src/application/javascript/JavaScriptApplicationCancellation.test.ts @@ -0,0 +1,75 @@ +import { setImmediate } from "node:timers"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js"; + +describe("JavaScript analysis cancellation before publication", () => { + it.each([ + "parse_javascript_source", + "build_javascript_application_graph", + "build_javascript_semantic_graph", + "validate_javascript_application_result", + "create_javascript_application_evidence", + "seal_javascript_application_result", + "validate_javascript_application_evidence", + "hash_javascript_application_evidence", + ])("rejects cancellation scheduled during %s", async (phase) => { + const root = await createTestTempDirectory("rea-js-cancel-phase-"); + await writeFile( + join(root, "main.js"), + "export function inspect(value) { return JSON.stringify(value); }\n", + ); + const controller = new AbortController(); + let requested = false; + const terminal: string[] = []; + const result = await analyzeJavaScriptApplication( + { input_path: root, format: "directory" }, + { + signal: controller.signal, + progress: { + async report(event) { + if (event.terminal) terminal.push(event.phase); + if (event.phase === phase) { + requested = true; + setImmediate(() => controller.abort()); + } + }, + }, + }, + ); + expect(requested).toBe(true); + expect(result.ok).toBe(false); + if (result.ok) + throw new Error("Cancelled analysis must not publish Evidence"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cancelled", + details: { reason: "cancelled" }, + }); + expect(terminal).toEqual([]); + }); + + it("honors cancellation observed during the terminal publication race", async () => { + const root = await createTestTempDirectory("rea-js-cancel-terminal-"); + await writeFile(join(root, "main.js"), "export const value = 1;\n"); + const controller = new AbortController(); + const result = await analyzeJavaScriptApplication( + { input_path: root, format: "directory" }, + { + signal: controller.signal, + progress: { + async report(event) { + if (event.terminal) controller.abort(); + }, + }, + }, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected cancellation before admission"); + expect(projectAnalysisError(result.error)).toMatchObject({ + details: { reason: "cancelled" }, + }); + }); +}); diff --git a/src/application/javascript/JavaScriptApplicationEvidence.ts b/src/application/javascript/JavaScriptApplicationEvidence.ts index 53d7087e0..9aeb081f8 100644 --- a/src/application/javascript/JavaScriptApplicationEvidence.ts +++ b/src/application/javascript/JavaScriptApplicationEvidence.ts @@ -1,5 +1,6 @@ import { createEvidence, + createImmutableEvidenceSteps, type Evidence, type EvidenceObservation, } from "../../domain/evidence.js"; @@ -8,8 +9,14 @@ import type { JavaScriptApplicationAnalysisResult, } from "../../domain/javascript/javascriptApplicationAnalysis.js"; import { JAVASCRIPT_APPLICATION_PROVIDER } from "../InvestigationProviders.js"; -import { freezeJsonSnapshot } from "../../domain/immutableJson.js"; +import { + freezeJsonSnapshot, + freezeOwnedJsonSnapshotSteps, +} from "../../domain/immutableJson.js"; import { rememberOwnedApplicationGraphEvidence } from "./JavaScriptApplicationEvidenceGraph.js"; +import { completeJavaScriptAnalysisSteps } from "./JavaScriptAnalysisControl.js"; +import { jsonValueValidationSteps } from "../../domain/jsonValue.js"; +import type { ProgressReporter } from "../ProgressReporter.js"; /** Transfer an already validated, application-owned result into immutable analysis Evidence. */ export const createOwnedJavaScriptApplicationEvidence = ( @@ -21,32 +28,89 @@ export const createOwnedJavaScriptApplicationEvidence = ( result, ); +/** Create owned analysis Evidence while allowing request cancellation during sealing and hashing. */ +export const createOwnedJavaScriptApplicationEvidenceCooperatively = async ( + input: AnalyzeJavaScriptApplicationInput, + result: JavaScriptApplicationAnalysisResult, + signal?: AbortSignal, + progress?: ProgressReporter, +): Promise => { + await reportEvidencePhase( + progress, + "seal_javascript_application_result", + "Sealing the complete application analysis result", + ); + const snapshot = await completeJavaScriptAnalysisSteps( + freezeOwnedJsonSnapshotSteps(result), + signal, + ); + await reportEvidencePhase( + progress, + "validate_javascript_application_evidence", + "Validating the immutable application Evidence JSON", + ); + // The Evidence envelope consumes the completed immutable validation cache. + await completeJavaScriptAnalysisSteps( + jsonValueValidationSteps(snapshot), + signal, + ); + await reportEvidencePhase( + progress, + "hash_javascript_application_evidence", + "Hashing the complete application analysis Evidence", + ); + const evidence = await completeJavaScriptAnalysisSteps( + createImmutableEvidenceSteps( + applicationSubject(snapshot), + JAVASCRIPT_APPLICATION_PROVIDER, + applicationObservation(input, snapshot), + ), + signal, + ); + return rememberOwnedApplicationGraphEvidence(evidence, snapshot); +}; + +const reportEvidencePhase = ( + progress: ProgressReporter | undefined, + phase: string, + message: string, +): Promise => + progress?.report({ phase, completed: 0, total: 1, message }) ?? + Promise.resolve(); + /** Create Evidence for one deterministic local JavaScript application graph. */ export const createJavaScriptApplicationEvidence = ( input: AnalyzeJavaScriptApplicationInput, result: JavaScriptApplicationAnalysisResult, ): Evidence => createEvidence( - { - path: result.input_path, - sha256: result.root_artifact_sha256, - format: result.format, - }, + applicationSubject(result), JAVASCRIPT_APPLICATION_PROVIDER, - { - predicateType: "rea.javascript-application-analysis", - operation: "analyze_javascript_application", - parameters: parameters(input), - result, - rawResult: null, - confidence: "derived", - authority: "shipped-artifact", - environment: null, - limitations: result.limitations, - locations: [{ kind: "artifact-path", path: "artifact-root" }], - }, + applicationObservation(input, result), ); +const applicationSubject = (result: JavaScriptApplicationAnalysisResult) => ({ + path: result.input_path, + sha256: result.root_artifact_sha256, + format: result.format, +}); + +const applicationObservation = ( + input: AnalyzeJavaScriptApplicationInput, + result: JavaScriptApplicationAnalysisResult, +): EvidenceObservation => ({ + predicateType: "rea.javascript-application-analysis", + operation: "analyze_javascript_application", + parameters: parameters(input), + result, + rawResult: null, + confidence: "derived", + authority: "shipped-artifact", + environment: null, + limitations: result.limitations, + locations: [{ kind: "artifact-path", path: "artifact-root" }], +}); + const parameters = ( input: AnalyzeJavaScriptApplicationInput, ): EvidenceObservation["parameters"] => ({ diff --git a/src/application/javascript/JavaScriptApplicationService.ts b/src/application/javascript/JavaScriptApplicationService.ts index 7766653b9..0bd2ead7a 100644 --- a/src/application/javascript/JavaScriptApplicationService.ts +++ b/src/application/javascript/JavaScriptApplicationService.ts @@ -13,7 +13,11 @@ import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection. import { err, ok, type Result } from "../../domain/result.js"; import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import type { ExecutionOptions } from "../AnalysisProvider.js"; -import { createOwnedJavaScriptApplicationEvidence } from "./JavaScriptApplicationEvidence.js"; +import { createOwnedJavaScriptApplicationEvidenceCooperatively } from "./JavaScriptApplicationEvidence.js"; +import { + assertJavaScriptAnalysisActive, + checkpointJavaScriptAnalysis, +} from "./JavaScriptAnalysisControl.js"; import { reconstructJavaScriptArtifact } from "./JavaScriptArtifactReconstruction.js"; import { JAVASCRIPT_APPLICATION_PROVIDER } from "../InvestigationProviders.js"; @@ -37,13 +41,15 @@ export const analyzeJavaScriptApplicationValidated = async ( input: z.output, options: ExecutionOptions = {}, ): Promise> => { - await options.progress?.report({ - phase: "analyze_javascript_application", - completed: 0, - total: 1, - message: "Inventorying and parsing application artifacts", - }); try { + assertJavaScriptAnalysisActive(options.signal); + await options.progress?.report({ + phase: "analyze_javascript_application", + completed: 0, + total: 1, + message: "Inventorying and parsing application artifacts", + }); + await checkpointJavaScriptAnalysis(options.signal); const reconstructed = await reconstructJavaScriptArtifact( { input_path: input.input_path, @@ -59,6 +65,7 @@ export const analyzeJavaScriptApplicationValidated = async ( total: 1, message: "Validating the application analysis result", }); + await checkpointJavaScriptAnalysis(options.signal); const result = javascriptApplicationAnalysisResultSchema.parse({ ...application, summary, @@ -70,7 +77,15 @@ export const analyzeJavaScriptApplicationValidated = async ( total: 1, message: "Creating and hashing application analysis Evidence", }); - const evidence = createOwnedJavaScriptApplicationEvidence(input, result); + await checkpointJavaScriptAnalysis(options.signal); + const evidence = + await createOwnedJavaScriptApplicationEvidenceCooperatively( + input, + result, + options.signal, + options.progress, + ); + await checkpointJavaScriptAnalysis(options.signal); await options.progress?.report({ phase: "analyze_javascript_application", completed: 1, @@ -78,6 +93,7 @@ export const analyzeJavaScriptApplicationValidated = async ( message: "Application graph and Electron boundaries reconstructed", terminal: true, }); + assertJavaScriptAnalysisActive(options.signal); return ok(evidence); } catch (cause: unknown) { if (cause instanceof ArtifactReaderFailure) diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 57575552f..4d4f502fb 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -16,6 +16,7 @@ import { scanCanonicalArtifactInventory } from "../../artifacts/inventory/Artifa import { summarizeElectronBoundaries } from "./ElectronBoundaryAnalysis.js"; import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js"; import type { ProgressReporter } from "../ProgressReporter.js"; +import { checkpointJavaScriptAnalysis } from "./JavaScriptAnalysisControl.js"; /** Application-layer result retaining local diagnostics outside the canonical graph. */ export interface JavaScriptArtifactReconstructionResult { @@ -50,6 +51,7 @@ export const reconstructJavaScriptArtifact = async ( ): Promise => { const reportPhase = async (phase: string, message: string): Promise => { await progress?.report({ phase, completed: 0, total: 1, message }); + await checkpointJavaScriptAnalysis(signal); }; const input = javascriptArtifactReconstructionInputSchema.parse(rawInput); abortIfNeeded(signal); @@ -87,6 +89,7 @@ export const reconstructJavaScriptArtifact = async ( abortIfNeeded(signal); }, ); + await checkpointJavaScriptAnalysis(signal); abortIfNeeded(signal); await reportPhase( "build_javascript_application_graph", @@ -102,6 +105,7 @@ export const reconstructJavaScriptArtifact = async ( graph, analysis, ); + await checkpointJavaScriptAnalysis(signal); return { input_path: path, format, diff --git a/src/cli/electronCommands.ts b/src/cli/electronCommands.ts index e747dc5c9..c24fd472a 100644 --- a/src/cli/electronCommands.ts +++ b/src/cli/electronCommands.ts @@ -25,6 +25,7 @@ import { } from "../cliObservationOptions.js"; import { runCliJavaScriptApplicationAnalysis } from "./javascriptApplicationAnalysis.js"; import type { CliResultOutput } from "./streamedJsonOutput.js"; +import { withCommandCancellation } from "./commandCancellation.js"; /** Register CLI equivalents of the Electron MCP tools. */ export const registerElectronCommands = ( @@ -184,16 +185,19 @@ const registerJavaScriptApplicationCommand = ( }), options: javascriptApplicationOptions, run: ({ args, options, format }) => - logCliCommand(logger, CLI_COMMANDS.analyzeJavaScriptApplication, () => - runCliJavaScriptApplicationAnalysis( - { input_path: args.path, format: options.artifactFormat }, - resultOutput === undefined - ? undefined - : { - output: resultOutput, - command: CLI_COMMANDS.analyzeJavaScriptApplication, - format, - }, + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.analyzeJavaScriptApplication, () => + runCliJavaScriptApplicationAnalysis( + { input_path: args.path, format: options.artifactFormat }, + resultOutput === undefined + ? undefined + : { + output: resultOutput, + command: CLI_COMMANDS.analyzeJavaScriptApplication, + format, + }, + signal, + ), ), ), }); diff --git a/src/cli/javascriptApplicationAnalysis.ts b/src/cli/javascriptApplicationAnalysis.ts index 92d4696c4..150a7a913 100644 --- a/src/cli/javascriptApplicationAnalysis.ts +++ b/src/cli/javascriptApplicationAnalysis.ts @@ -8,6 +8,7 @@ import type { CliCommandOutput } from "./streamedJsonOutput.js"; export const runCliJavaScriptApplicationAnalysis = async ( input: unknown, output?: CliCommandOutput, + signal?: AbortSignal, ): Promise => { const started = performance.now(); const progress = createProgressReporter( @@ -16,7 +17,10 @@ export const runCliJavaScriptApplicationAnalysis = async ( }, { minimumIntervalMs: 0 }, ); - const result = await analyzeJavaScriptApplication(input, { progress }); + const result = await analyzeJavaScriptApplication(input, { + progress, + ...(signal === undefined ? {} : { signal }), + }); const value = result.ok ? result.value : cliError(result.error); if ( output !== undefined && @@ -26,7 +30,8 @@ export const runCliJavaScriptApplicationAnalysis = async ( duration: `${Math.round(performance.now() - started)}ms`, })) ) - return undefined; + // Keep failures visible to command logging after the stream has handled stdout. + return result.ok ? undefined : value; return value; }; diff --git a/src/domain/canonicalJsonDigestSteps.test.ts b/src/domain/canonicalJsonDigestSteps.test.ts new file mode 100644 index 000000000..f5346b926 --- /dev/null +++ b/src/domain/canonicalJsonDigestSteps.test.ts @@ -0,0 +1,66 @@ +import { fc, it } from "@fast-check/vitest"; +import { describe, expect } from "vitest"; +import { canonicalJsonDigestSteps } from "./canonicalJsonDigestSteps.js"; +import { digestCanonicalValue } from "./canonicalDigest.js"; +import { jsonValueSchema, type JsonValue } from "./jsonValue.js"; + +const complete = (steps: Iterator): Value => { + for (;;) { + const next = steps.next(); + if (next.done) return next.value; + } +}; + +describe("cooperative canonical JSON digest", () => { + const values: readonly JsonValue[] = [ + null, + [true, false, 0, -0, 1e-7, 1e21, "\ud800", '𐀀\n"'], + { z: null, a: { b: 2, a: 1 } }, + jsonValueSchema.parse( + JSON.parse('{"__proto__":{"x":1},"constructor":2,"toJSON":"text"}'), + ), + { payload: `${"x".repeat(8191)}𐀀${"\n\ud800".repeat(20_000)}` }, + { [`${"x".repeat(8191)}𐀀${"\n".repeat(20_000)}`]: "value" }, + ]; + it.each(values.map((value) => ({ value })))( + "preserves complete canonical meaning (%#)", + ({ value }) => { + expect( + complete(canonicalJsonDigestSteps(jsonValueSchema.parse(value))), + ).toBe(digestCanonicalValue(value)); + }, + ); + + it("supports repeated references without treating them as cycles", () => { + const shared = { nested: ["value"] }; + const value = { a: shared, b: shared }; + expect(complete(canonicalJsonDigestSteps(value))).toBe( + digestCanonicalValue(value), + ); + }); + + it.prop([fc.jsonValue()])( + "matches the existing digest across JSON values", + (value) => { + expect( + complete(canonicalJsonDigestSteps(jsonValueSchema.parse(value))), + ).toBe(digestCanonicalValue(value)); + }, + ); + + it("permits abandoning a large string before completing its digest", () => { + const steps = canonicalJsonDigestSteps("𐀀".repeat(100_000)); + expect(steps.next().done).toBe(false); + steps.return("abandoned"); + expect(steps.next().done).toBe(true); + }); + + it("rejects sparse arrays, nonfinite numbers and cycles", () => { + const sparse: JsonValue[] = []; + sparse.length = 2; + const cycle: { [key: string]: JsonValue } = {}; + cycle.self = cycle; + for (const value of [sparse, Number.NaN, cycle]) + expect(() => complete(canonicalJsonDigestSteps(value))).toThrow(); + }); +}); diff --git a/src/domain/canonicalJsonDigestSteps.ts b/src/domain/canonicalJsonDigestSteps.ts new file mode 100644 index 000000000..ef4ab8841 --- /dev/null +++ b/src/domain/canonicalJsonDigestSteps.ts @@ -0,0 +1,156 @@ +import { createHash } from "node:crypto"; +import type { JsonValue } from "./jsonValue.js"; + +type DigestFrame = + | { readonly kind: "separator" } + | { readonly kind: "value"; readonly value: JsonValue } + | { readonly kind: "string"; readonly value: string; index: number } + | { + readonly kind: "array"; + readonly value: readonly JsonValue[]; + readonly length: number; + index: number; + } + | { + readonly kind: "object"; + readonly value: { readonly [key: string]: JsonValue }; + readonly keys: readonly string[]; + index: number; + written: number; + }; + +/** Hash owned, validated JSON in bounded steps without a document-sized string. */ +export function* canonicalJsonDigestSteps( + value: JsonValue, +): Generator { + const hash = createHash("sha256"); + const ancestors = new Set(); + const stack: DigestFrame[] = [{ kind: "value", value }]; + let buffered = ""; + let flushed = false; + const emit = (part: string): void => { + buffered += part; + if (buffered.length >= 8192) { + hash.update(buffered); + buffered = ""; + flushed = true; + } + }; + try { + while (stack.length > 0) { + if (flushed) { + flushed = false; + yield; + } + const frame = stack.pop(); + if (frame === undefined) break; + if (frame.kind === "separator") { + emit(":"); + continue; + } + if (frame.kind === "string") { + if (frame.index === frame.value.length) { + emit('"'); + continue; + } + let end = Math.min(frame.index + 8192, frame.value.length); + const last = frame.value.charCodeAt(end - 1); + const next = frame.value.charCodeAt(end); + if ( + end < frame.value.length && + last >= 0xd800 && + last <= 0xdbff && + next >= 0xdc00 && + next <= 0xdfff + ) + end -= 1; + emit(JSON.stringify(frame.value.slice(frame.index, end)).slice(1, -1)); + frame.index = end; + stack.push(frame); + continue; + } + if (frame.kind === "array") { + if (frame.index === frame.length) { + emit("]"); + ancestors.delete(frame.value); + continue; + } + const item = frame.value[frame.index]; + if (item === undefined) + throw new TypeError( + "Canonical JSON digest requires dense JSON arrays", + ); + if (frame.index++ > 0) emit(","); + stack.push(frame, { kind: "value", value: item }); + continue; + } + if (frame.kind === "object") { + if (frame.index === frame.keys.length) { + emit("}"); + ancestors.delete(frame.value); + continue; + } + const key = frame.keys[frame.index++]; + if (key === undefined) + throw new TypeError("Canonical JSON object changed during hashing"); + const item = frame.value[key]; + if (item === undefined) continue; + if (frame.written++ > 0) emit(","); + if (key.length > 8192) { + emit('"'); + stack.push( + frame, + { kind: "value", value: item }, + { kind: "separator" }, + { kind: "string", value: key, index: 0 }, + ); + continue; + } + emit(JSON.stringify(key)); + emit(":"); + stack.push(frame, { kind: "value", value: item }); + continue; + } + const item = frame.value; + if (typeof item === "string" && item.length > 8192) { + emit('"'); + stack.push({ kind: "string", value: item, index: 0 }); + } else if (item === null || typeof item !== "object") { + if (typeof item === "number" && !Number.isFinite(item)) + throw new Error( + Number.isNaN(item) + ? "NaN is not allowed" + : "Infinity is not allowed", + ); + emit(JSON.stringify(item)); + } else { + if (ancestors.has(item)) throw new Error("Circular reference detected"); + ancestors.add(item); + if (Array.isArray(item)) { + emit("["); + stack.push({ + kind: "array", + value: item, + length: item.length, + index: 0, + }); + } else { + emit("{"); + stack.push({ + kind: "object", + value: item, + keys: Object.keys(item).sort(), + index: 0, + written: 0, + }); + } + } + } + if (buffered !== "") hash.update(buffered); + return hash.digest("hex"); + } finally { + stack.length = 0; + ancestors.clear(); + buffered = ""; + } +} diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index 8c6b0fb01..074a5783c 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { digestCanonicalValue } from "./canonicalDigest.js"; +import { canonicalJsonDigestSteps } from "./canonicalJsonDigestSteps.js"; import { analysisProfileSchema, @@ -11,6 +12,7 @@ import { jsonObjectSchema, jsonValueSchema, jsonValueValidationIssue, + jsonValueValidationSteps, type JsonValue, } from "./jsonValue.js"; import { @@ -223,7 +225,7 @@ const semanticProjection = (evidence: EvidenceWithoutId): JsonValue => ({ evidence_links: evidence.evidence_links, }); -/** Recompute the semantic identifier, excluding paths and raw payload bytes. */ +/** Hash semantic content, including raw results and excluding display-subject fields. */ const computeEvidenceId = (evidence: EvidenceWithoutId): string => `ev_${digestCanonicalValue(semanticProjection(evidence), "Evidence")}`; @@ -270,6 +272,50 @@ export const createEvidence = ( provider: EvidenceProvider, observation: EvidenceObservation, ): Evidence => { + const { normalized, sharedResult } = normalizeEvidenceObservation( + target, + provider, + observation, + ); + const evidence = { + ...normalized, + evidence_id: computeEvidenceId(normalized), + }; + return sharedResult ? rememberImmutableEvidence(evidence) : evidence; +}; + +/** Create Evidence from owned immutable JSON while exposing cooperative computation steps. */ +export function* createImmutableEvidenceSteps( + target: EvidenceSubjectTarget | BinaryTarget | undefined, + provider: EvidenceProvider, + observation: EvidenceObservation, +): Generator { + if (!isImmutableJsonSnapshot(observation.result)) + throw new TypeError( + "Cooperative Evidence requires an authenticated immutable result", + ); + // The envelope parser below retains its usual diagnostics, using the completed + // immutable validation rather than traversing the payload synchronously again. + yield* jsonValueValidationSteps(observation.result); + const { normalized } = normalizeEvidenceObservation( + target, + provider, + observation, + ); + const digest = yield* canonicalJsonDigestSteps( + semanticProjection(normalized), + ); + return rememberImmutableEvidence({ + ...normalized, + evidence_id: `ev_${digest}`, + }); +} + +const normalizeEvidenceObservation = ( + target: EvidenceSubjectTarget | BinaryTarget | undefined, + provider: EvidenceProvider, + observation: EvidenceObservation, +) => { const subject = target === undefined ? null @@ -339,9 +385,5 @@ export const createEvidence = ( // The envelope has already been parsed into an independent snapshot. Only // its derived identifier changes here; parsing again clones the full payload // and recomputes the same digest while the previous snapshot is still live. - const evidence = { - ...normalized, - evidence_id: computeEvidenceId(normalized), - }; - return sharedResult ? rememberImmutableEvidence(evidence) : evidence; + return { normalized, sharedResult }; }; diff --git a/src/domain/evidenceSteps.test.ts b/src/domain/evidenceSteps.test.ts new file mode 100644 index 000000000..1eadacc00 --- /dev/null +++ b/src/domain/evidenceSteps.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { createAnalysisProfile } from "./analysisProfile.js"; +import { + createEvidence, + createImmutableEvidenceSteps, + isImmutableEvidence, + parseEvidence, +} from "./evidence.js"; +import { freezeJsonSnapshot } from "./immutableJson.js"; +import { + jsonValueValidationSteps, + MAX_JSON_DEPTH, + type JsonValue, +} from "./jsonValue.js"; + +const provider = { + id: "cooperative", + name: "Cooperative fixture", + version: "1", +}; +const target = { + path: "/artifact", + sha256: "a".repeat(64), + format: "file", +} as const; + +const finish = (steps: Iterator): Value => { + for (;;) { + const step = steps.next(); + if (step.done) return step.value; + } +}; + +describe("cooperative immutable Evidence", () => { + it.each([false, true])( + "preserves the complete envelope and identifier (profile: %s)", + (profiled) => { + const result = freezeJsonSnapshot({ + ["__proto__"]: { preserved: [1, "\ud800", "šŸ˜€", null] }, + constructor: "ordinary member", + long: 'quote"\\\nšŸ˜€'.repeat(5000), + }); + const observation = { + operation: "inspect", + parameters: { ["__proto__"]: "caller-selected" }, + result, + rawResult: { retained: true }, + ...(profiled + ? { analysisProfile: createAnalysisProfile(provider, {}) } + : {}), + }; + const ordinary = createEvidence(target, provider, observation); + const cooperative = finish( + createImmutableEvidenceSteps(target, provider, observation), + ); + expect(JSON.stringify(cooperative)).toBe(JSON.stringify(ordinary)); + expect(cooperative.normalized_result).toBe(result); + expect(isImmutableEvidence(cooperative)).toBe(true); + expect(parseEvidence(cooperative)).toBe(cooperative); + expect(parseEvidence(JSON.parse(JSON.stringify(cooperative)))).toEqual( + ordinary, + ); + }, + ); + + it("retains scalar results and unavailable-subject limitations", () => { + const observation = { operation: "inspect", parameters: {}, result: null }; + expect( + finish(createImmutableEvidenceSteps(undefined, provider, observation)), + ).toEqual(createEvidence(undefined, provider, observation)); + }); + + it("rejects mutable results and mismatched producer profiles", () => { + expect(() => + finish( + createImmutableEvidenceSteps(target, provider, { + operation: "inspect", + parameters: {}, + result: { borrowed: true }, + }), + ), + ).toThrow("authenticated immutable result"); + expect(() => + finish( + createImmutableEvidenceSteps(target, provider, { + operation: "inspect", + parameters: {}, + result: freezeJsonSnapshot({ value: true }), + analysisProfile: createAnalysisProfile( + { ...provider, id: "different" }, + {}, + ), + }), + ), + ).toThrow("profile provider"); + }); + + it("does not cache an interrupted depth validation as successful", () => { + let nested: JsonValue = 1; + for (let index = 0; index <= MAX_JSON_DEPTH; index += 1) + nested = { nested }; + const result = freezeJsonSnapshot([ + nested, + ...Array(5000).fill(null), + ]); + const validation = jsonValueValidationSteps(result); + expect(validation.next().done).toBe(false); + validation.return(undefined); + const observation = { operation: "inspect", parameters: {}, result }; + expect(() => + finish(createImmutableEvidenceSteps(target, provider, observation)), + ).toThrow("maximum nesting depth"); + expect(() => createEvidence(target, provider, observation)).toThrow( + "maximum nesting depth", + ); + }); +}); diff --git a/src/domain/immutableJson.ts b/src/domain/immutableJson.ts index 4c0579e71..0b68b4e2b 100644 --- a/src/domain/immutableJson.ts +++ b/src/domain/immutableJson.ts @@ -1,6 +1,10 @@ import type { JsonValue } from "./jsonValue.js"; const immutableSnapshots = new WeakSet(); +interface SnapshotEntry { + readonly value: unknown; + readonly exiting: boolean; +} /** Recognize an owned JSON snapshot sealed by this module, or an immutable scalar. */ export const isImmutableJsonSnapshot = (value: unknown): value is JsonValue => @@ -12,9 +16,7 @@ export const isImmutableJsonSnapshot = (value: unknown): value is JsonValue => /** Seal an already parsed, owned JSON snapshot for immutable zero-copy reuse. */ export const freezeJsonSnapshot = (value: T): T => { - const pending: { value: unknown; exiting: boolean }[] = [ - { value, exiting: false }, - ]; + const pending: SnapshotEntry[] = [{ value, exiting: false }]; // These sets exist only during traversal, while the owned root already // retains every child. Strong sets avoid per-node ephemeron GC work. const visited = new Set(); @@ -33,13 +35,6 @@ export const freezeJsonSnapshot = (value: T): T => { if (isImmutableJsonSnapshot(item)) continue; if (typeof item !== "object" || item === null) throw new TypeError("Immutable JSON snapshots require JSON values"); - const prototype: unknown = Object.getPrototypeOf(item); - if ( - !Array.isArray(item) && - prototype !== Object.prototype && - prototype !== null - ) - throw new TypeError("Immutable JSON snapshots require ordinary objects"); if (ancestors.has(item)) throw new TypeError( "Immutable JSON snapshots cannot contain circular references", @@ -47,21 +42,7 @@ export const freezeJsonSnapshot = (value: T): T => { if (visited.has(item)) continue; ancestors.add(item); pending.push({ value: item, exiting: true }); - if (Array.isArray(item)) - for (let index = 0; index < item.length; index += 1) - if (!Object.hasOwn(item, index)) - throw new TypeError( - "Immutable JSON snapshots cannot contain sparse arrays", - ); - for (const key of Object.getOwnPropertyNames(item)) { - const descriptor = Object.getOwnPropertyDescriptor(item, key); - if (descriptor === undefined || !Object.hasOwn(descriptor, "value")) - throw new TypeError( - "Immutable JSON snapshots cannot contain accessors", - ); - const child: unknown = descriptor.value; - pending.push({ value: child, exiting: false }); - } + pushSnapshotChildren(item, pending); } // Register only after every value is checked and every object is frozen. // An arbitrary Object.freeze call cannot forge this snapshot identity. @@ -72,3 +53,75 @@ export const freezeJsonSnapshot = (value: T): T => { immutableSnapshots.add(value); return value; }; + +/** + * Seal exclusively owned JSON in bounded steps, authenticating only completion. + * Abandonment or failure can leave the provisional input partly frozen. + */ +export function* freezeOwnedJsonSnapshotSteps( + value: T, +): Generator { + const pending: SnapshotEntry[] = [{ value, exiting: false }]; + const visited = new Set(); + const ancestors = new Set(); + let examined = 0; + try { + while (pending.length > 0) { + if (examined++ === 4096) { + examined = 0; + yield; + } + const entry = pending.pop(); + if (entry === undefined) break; + const item = entry.value; + if (entry.exiting && typeof item === "object" && item !== null) { + ancestors.delete(item); + visited.add(item); + continue; + } + if (isImmutableJsonSnapshot(item)) continue; + if (typeof item !== "object" || item === null) + throw new TypeError("Immutable JSON snapshots require JSON values"); + if (ancestors.has(item)) + throw new TypeError( + "Immutable JSON snapshots cannot contain circular references", + ); + if (visited.has(item)) continue; + ancestors.add(item); + pending.push({ value: item, exiting: true }); + pushSnapshotChildren(item, pending); + // Lock every inspected edge before the scheduler can release control. + Object.freeze(item); + } + if (typeof value === "object" && value !== null) + immutableSnapshots.add(value); + return value; + } finally { + pending.length = 0; + visited.clear(); + ancestors.clear(); + } +} + +const pushSnapshotChildren = (item: object, pending: SnapshotEntry[]): void => { + const prototype: unknown = Object.getPrototypeOf(item); + if ( + !Array.isArray(item) && + prototype !== Object.prototype && + prototype !== null + ) + throw new TypeError("Immutable JSON snapshots require ordinary objects"); + if (Array.isArray(item)) + for (let index = 0; index < item.length; index += 1) + if (!Object.hasOwn(item, index)) + throw new TypeError( + "Immutable JSON snapshots cannot contain sparse arrays", + ); + for (const key of Object.getOwnPropertyNames(item)) { + const descriptor = Object.getOwnPropertyDescriptor(item, key); + if (descriptor === undefined || !Object.hasOwn(descriptor, "value")) + throw new TypeError("Immutable JSON snapshots cannot contain accessors"); + const child: unknown = descriptor.value; + pending.push({ value: child, exiting: false }); + } +}; diff --git a/src/domain/immutableJsonSteps.test.ts b/src/domain/immutableJsonSteps.test.ts new file mode 100644 index 000000000..0755e75f0 --- /dev/null +++ b/src/domain/immutableJsonSteps.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { + freezeOwnedJsonSnapshotSteps, + isImmutableJsonSnapshot, +} from "./immutableJson.js"; +import { jsonValueValidationIssue, type JsonValue } from "./jsonValue.js"; + +const complete = (steps: Iterator): Value => { + for (;;) { + const next = steps.next(); + if (next.done) return next.value; + } +}; + +describe("cooperative owned snapshot sealing", () => { + it("locks reachable edges before yielding and authenticates only completion", () => { + const deferred: { [key: string]: JsonValue } = { value: 1 }; + const value = { + deferred, + many: Array.from({ length: 10_000 }, () => ({ value: 1 })), + }; + const steps = freezeOwnedJsonSnapshotSteps(value); + expect(steps.next().done).toBe(false); + expect(Object.isFrozen(value)).toBe(true); + expect(isImmutableJsonSnapshot(value)).toBe(false); + const introduced = { value: 2 }; + deferred.child = introduced; + expect(complete(steps)).toBe(value); + expect(Object.isFrozen(introduced)).toBe(true); + expect(isImmutableJsonSnapshot(value)).toBe(true); + expect(jsonValueValidationIssue(value)).toBeUndefined(); + }); + + it("never authenticates abandoned or invalid provisional roots", () => { + const deferred: { [key: string]: JsonValue } = { value: 1 }; + const value = { + deferred, + many: Array.from({ length: 10_000 }, () => ({ value: 1 })), + }; + const steps = freezeOwnedJsonSnapshotSteps(value); + expect(steps.next().done).toBe(false); + deferred.value = Number.NaN; + expect(() => complete(steps)).toThrow(); + expect(isImmutableJsonSnapshot(value)).toBe(false); + const cancelled = { + values: Array.from({ length: 10_000 }, () => ({ value: 1 })), + }; + const interrupted = freezeOwnedJsonSnapshotSteps(cancelled); + expect(interrupted.next().done).toBe(false); + interrupted.return(cancelled); + expect(isImmutableJsonSnapshot(cancelled)).toBe(false); + }); + + it("preserves aliases and rejects accessors, sparse values and cycles", () => { + const shared = { value: 1 }; + const value = { a: shared, b: shared }; + complete(freezeOwnedJsonSnapshotSteps(value)); + expect(Object.isFrozen(shared)).toBe(true); + const sparse: JsonValue[] = []; + sparse.length = 2; + const cycle: { [key: string]: JsonValue } = {}; + cycle.self = cycle; + let accessed = false; + const accessor = { + get value() { + accessed = true; + return 1; + }, + }; + for (const malformed of [sparse, cycle, accessor]) { + expect(() => complete(freezeOwnedJsonSnapshotSteps(malformed))).toThrow(); + expect(isImmutableJsonSnapshot(malformed)).toBe(false); + } + expect(accessed).toBe(false); + }); + + it("does not cache validation of a caller's shallow freeze", () => { + const value = Object.freeze({ child: { value: 1 } }); + expect(jsonValueValidationIssue(value)).toBeUndefined(); + value.child.value = Number.NaN; + expect(jsonValueValidationIssue(value)).toBe("JSON numbers must be finite"); + }); +}); diff --git a/src/domain/jsonValue.ts b/src/domain/jsonValue.ts index 721c22c53..a6111a6c5 100644 --- a/src/domain/jsonValue.ts +++ b/src/domain/jsonValue.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { isImmutableJsonSnapshot } from "./immutableJson.js"; /** Maximum JSON nesting accepted before recursive result cloning. */ export const MAX_JSON_DEPTH = 1000; @@ -15,17 +16,46 @@ export type JsonValue = const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); const recordShape = z.record(z.string(), z.unknown()); +const immutableValidation = new WeakMap(); /** Validate JSON meaning and nesting without cloning a trusted immutable value. */ export const jsonValueValidationIssue = (root: unknown): string | undefined => { + const steps = jsonValueValidationSteps(root); + for (;;) { + const step = steps.next(); + if (step.done) return step.value; + } +}; + +/** Validate JSON in bounded steps; cache only authenticated immutable roots. */ +export function* jsonValueValidationSteps( + root: unknown, +): Generator { + const immutable = + typeof root === "object" && root !== null && isImmutableJsonSnapshot(root) + ? root + : undefined; + if (immutable !== undefined && immutableValidation.has(immutable)) + return immutableValidation.get(immutable); + const finish = (issue: string | undefined): string | undefined => { + if (immutable !== undefined) immutableValidation.set(immutable, issue); + return issue; + }; const pending: { value: unknown; depth: number }[] = [ { value: root, depth: 0 }, ]; + let examined = 0; while (pending.length > 0) { + if (examined++ === 4096) { + examined = 0; + yield; + } const item = pending.pop(); if (item === undefined) break; if (item.depth > MAX_JSON_DEPTH) - return `JSON value exceeds maximum nesting depth of ${MAX_JSON_DEPTH}`; + return finish( + `JSON value exceeds maximum nesting depth of ${MAX_JSON_DEPTH}`, + ); const value = item.value; if ( value === null || @@ -34,7 +64,7 @@ export const jsonValueValidationIssue = (root: unknown): string | undefined => { ) continue; if (typeof value === "number") { - if (!Number.isFinite(value)) return "JSON numbers must be finite"; + if (!Number.isFinite(value)) return finish("JSON numbers must be finite"); } else if (Array.isArray(value)) { for (const child of value) pending.push({ value: child, depth: item.depth + 1 }); @@ -42,10 +72,10 @@ export const jsonValueValidationIssue = (root: unknown): string | undefined => { // Inspect every own member, including keys Zod's record parser skips. for (const child of Object.values(value)) pending.push({ value: child, depth: item.depth + 1 }); - } else return "Invalid JSON value"; + } else return finish("Invalid JSON value"); } - return undefined; -}; + return finish(undefined); +} const preserveJsonValue = (value: JsonValue): JsonValue => { if (Array.isArray(value)) return value.map(preserveJsonValue); diff --git a/tests/acceptance/applications/javascriptCancellationCli.test.ts b/tests/acceptance/applications/javascriptCancellationCli.test.ts new file mode 100644 index 000000000..9f02f583a --- /dev/null +++ b/tests/acceptance/applications/javascriptCancellationCli.test.ts @@ -0,0 +1,89 @@ +import { execFile, spawn } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, onTestFinished } from "vitest"; +import { z } from "zod"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("returns a nonzero exit for a streamed JSON analysis failure", async () => { + const root = await createTestTempDirectory("rea-cli-js-missing-"); + const result = await new Promise<{ code: number | string; stdout: string }>( + (resolve, reject) => { + execFile( + process.execPath, + [ + "scripts/rea.mjs", + "analyze-javascript-application", + join(root, "missing"), + "--json", + ], + (error, stdout) => { + if (error !== null && error.code === undefined) { + reject(error); + return; + } + resolve({ code: error?.code ?? 0, stdout }); + }, + ); + }, + ); + expect(result.code).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ + code: "artifact_operation_failed", + details: { reason: "io" }, + }); +}); + +it.skipIf(process.platform === "win32")( + "returns a typed cancelled CLI result and exit 130 after SIGINT", + async () => { + const root = await createTestTempDirectory("rea-cli-js-cancellation-"); + await writeFile(join(root, "main.js"), "export const observed = 1;\n"); + const child = spawn( + process.execPath, + ["scripts/rea.mjs", "analyze-javascript-application", root, "--json"], + { + stdio: ["ignore", "pipe", "pipe"], + }, + ); + onTestFinished(() => { + if (child.exitCode === null && child.signalCode === null) + child.kill("SIGTERM"); + }); + let stdout = "", + partial = "", + requested = false; + const progressSchema = z.object({ + rea_progress: z.object({ phase: z.string() }), + }); + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer) => { + partial += chunk.toString(); + let newline: number; + while ((newline = partial.indexOf("\n")) >= 0) { + const line = partial.slice(0, newline); + partial = partial.slice(newline + 1); + const parsed = progressSchema.safeParse(JSON.parse(line)); + if (parsed.success && !requested) { + requested = true; + child.kill("SIGINT"); + } + } + }); + const exited = await new Promise<{ + code: number | null; + signal: NodeJS.Signals | null; + }>((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => resolve({ code, signal })); + }); + expect(requested).toBe(true); + expect(exited).toEqual({ code: 130, signal: null }); + expect(JSON.parse(stdout)).toMatchObject({ + code: "cancelled", + details: { reason: "cancelled" }, + }); + }, +); diff --git a/tests/boundary/mcp/javascriptAnalysisCancellation.test.ts b/tests/boundary/mcp/javascriptAnalysisCancellation.test.ts new file mode 100644 index 000000000..7325ef763 --- /dev/null +++ b/tests/boundary/mcp/javascriptAnalysisCancellation.test.ts @@ -0,0 +1,110 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import pino from "pino"; +import { expect, it, onTestFinished } from "vitest"; +import { z } from "zod"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { + createDeferred, + createTestBinarySession, +} from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("stops a cancelled SDK request on the server, retains prior Evidence, and accepts subsequent work", async () => { + const root = await createTestTempDirectory("rea-mcp-js-cancellation-"); + await writeFile(join(root, "main.js"), "export const observed = 1;\n"); + const session = createTestBinarySession(() => { + throw new Error( + "Static JavaScript analysis must not start a binary provider", + ); + }); + const prior = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "prior_observation", + parameters: {}, + result: { preserved: true }, + }, + ); + expect(session.recordEvidence(prior).ok).toBe(true); + const completed = createDeferred(); + const completionSchema = z.object({ + tool: z.string(), + status: z.string(), + msg: z.string(), + }); + const logger = pino( + { level: "info" }, + { + write(line) { + const value: unknown = JSON.parse(line); + const parsed = completionSchema.safeParse(value); + if ( + parsed.success && + parsed.data.tool === "analyze_javascript_application" && + parsed.data.msg === "MCP tool execution completed" + ) + completed.resolve(parsed.data.status); + }, + }, + ); + const server = createServer(session, session, { logger }); + const client = new Client({ name: "javascript-cancellation", version: "1" }); + onTestFinished(async () => { + await client.close(); + await server.close(); + await session.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const controller = new AbortController(); + let requested = false; + const call = client.callTool( + { + name: "analyze_javascript_application", + arguments: { + input_path: root, + format: "directory", + }, + }, + { + signal: controller.signal, + onprogress() { + requested = true; + controller.abort(new Error("SDK cancellation verification")); + }, + }, + ); + await expect(call).rejects.toThrow("SDK cancellation verification"); + expect(requested).toBe(true); + // A rejected client promise alone does not prove that the server stopped. + expect(await completed.promise).toBe("error"); + await client.ping(); + const bundle = await client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }); + expect( + z + .object({ + result: z.object({ + records: z.array(z.object({ evidence_id: z.string() })), + }), + }) + .parse(bundle.structuredContent).result.records, + ).toEqual([{ evidence_id: prior.evidence_id }]); + const next = await client.callTool({ + name: "analyze_javascript_application", + arguments: { + input_path: root, + format: "directory", + }, + }); + expect(next.isError, JSON.stringify(next)).not.toBe(true); + await client.ping(); +});