fix(javascript): retain completed facts through cancellation (#1934)

This commit is contained in:
morluto
2026-10-12 02:39:52 +08:00
committed by GitHub
parent 0b2aa69531
commit b6651cb99f
6 changed files with 465 additions and 295 deletions
+5 -3
View File
@@ -159,9 +159,11 @@ completed partial Evidence. MCP failure details return
after recording succeeds, so `inspect_analysis_view` can open that analysis
directly without serializing the entire partial graph into the error response.
CLI failure output retains the complete partial Evidence.
Cancellation after source analysis begins likewise
retains completed facts; an SDK client may reject its cancelled request before
receiving that reply. On the same MCP connection, inspect the Evidence bundle
Cancellation after source analysis begins likewise retains completed facts,
including cancellation during analyzer cleanup, graph construction, or Evidence
sealing. REA finishes projecting those owned facts cooperatively to return partial
Evidence. An SDK client may reject its cancelled request before receiving that
reply. On the same MCP connection, inspect the Evidence bundle
and use the retained analysis ID with `inspect_analysis_view`. Owned analyzer
cleanup is verified before its private files are released. Cleanup uncertainty
is reported separately and retains ownership for retry.
@@ -4,6 +4,7 @@ import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import {
analyzeJavaScriptApplicationInputSchema,
type JavaScriptApplicationAnalysisResult,
parseOwnedJavaScriptApplicationAnalysisSteps,
} from "../../domain/javascript/javascriptApplicationAnalysis.js";
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
@@ -20,7 +21,10 @@ import {
checkpointJavaScriptAnalysis,
completeJavaScriptAnalysisSteps,
} from "./JavaScriptAnalysisControl.js";
import { reconstructJavaScriptArtifact } from "./JavaScriptArtifactReconstruction.js";
import {
reconstructJavaScriptArtifact,
type JavaScriptArtifactReconstructionResult,
} from "./JavaScriptArtifactReconstruction.js";
import { JAVASCRIPT_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
const OPERATION = "analyze_javascript_application" as const;
@@ -45,6 +49,9 @@ export const analyzeJavaScriptApplicationValidated = async (
scope: ArtifactResourceScope,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
let reconstructed: JavaScriptArtifactReconstructionResult | undefined;
let result: JavaScriptApplicationAnalysisResult | undefined;
let evidence: Evidence | undefined;
try {
assertJavaScriptAnalysisActive(options.signal);
await options.progress?.report({
@@ -54,7 +61,7 @@ export const analyzeJavaScriptApplicationValidated = async (
message: "Inventorying and parsing application artifacts",
});
await checkpointJavaScriptAnalysis(options.signal);
const reconstructed = await reconstructJavaScriptArtifact(
reconstructed = await reconstructJavaScriptArtifact(
{
input_path: input.input_path,
format: input.format,
@@ -78,7 +85,7 @@ export const analyzeJavaScriptApplicationValidated = async (
message: "Validating result metadata and cross-graph bindings",
});
await checkpointJavaScriptAnalysis(options.signal);
const result = await completeJavaScriptAnalysisSteps(
result = await completeJavaScriptAnalysisSteps(
parseOwnedJavaScriptApplicationAnalysisSteps({
...application,
summary,
@@ -93,13 +100,12 @@ export const analyzeJavaScriptApplicationValidated = async (
message: "Creating and hashing application analysis Evidence",
});
await checkpointJavaScriptAnalysis(options.signal);
const evidence =
await createOwnedJavaScriptApplicationEvidenceCooperatively(
input,
result,
options.signal,
options.progress,
);
evidence = await createOwnedJavaScriptApplicationEvidenceCooperatively(
input,
result,
options.signal,
options.progress,
);
await checkpointJavaScriptAnalysis(options.signal);
await options.progress?.report({
phase: "analyze_javascript_application",
@@ -111,6 +117,38 @@ export const analyzeJavaScriptApplicationValidated = async (
assertJavaScriptAnalysisActive(options.signal);
return ok(evidence);
} catch (cause: unknown) {
if (
cause instanceof ArtifactReaderFailure &&
cause.reason === "cancelled" &&
reconstructed !== undefined
) {
try {
if (result === undefined) {
const { electron_summary: summary, ...application } = reconstructed;
result = await completeJavaScriptAnalysisSteps(
parseOwnedJavaScriptApplicationAnalysisSteps({
...application,
summary,
limitations: reconstructed.graph.limitations,
}),
);
}
evidence ??=
await createOwnedJavaScriptApplicationEvidenceCooperatively(
input,
result,
);
return err(
artifactFailureToOperationError(cause).retainPartialObservation(
evidence,
),
);
} catch (completionCause: unknown) {
// A validation or Evidence failure while finishing the partial result
// remains the primary failure instead of being hidden by cancellation.
cause = completionCause;
}
}
// Preserve execution constraints and their completed partial Evidence.
if (cause instanceof AnalysisError) return err(cause);
if (cause instanceof ArtifactReaderFailure)
@@ -1,5 +1,6 @@
import { constants } from "node:fs";
import { access, lstat, realpath } from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import {
@@ -13,6 +14,7 @@ import type { JavaScriptSemanticGraph } from "../../domain/javascript/javascript
import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js";
import { parseOwnedJavaScriptApplicationAnalysisSteps } from "../../domain/javascript/javascriptApplicationAnalysis.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
import type { ArtifactInventoryPartialObservation } from "../../domain/artifactPartialObservation.js";
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
@@ -68,19 +70,17 @@ export const reconstructJavaScriptArtifact = async (
progress?: ProgressReporter,
): Promise<JavaScriptArtifactReconstructionResult> =>
scope.run(async () => {
let effectiveSignal = signal;
let effectiveProgress = progress;
const reportPhase = async (
phase: string,
message: string,
): Promise<void> => {
await effectiveProgress?.report({
await progress?.report({
phase,
completed: 0,
total: 1,
message,
});
await checkpointJavaScriptAnalysis(effectiveSignal);
await checkpointJavaScriptAnalysis(signal);
};
const input = javascriptArtifactReconstructionInputSchema.parse(rawInput);
abortIfNeeded(signal);
@@ -182,14 +182,24 @@ export const reconstructJavaScriptArtifact = async (
}
}
const analysis = outcome.analysis;
const interruption = cleanupFailure ?? outcome.interruption;
if (interruption !== null && interruption !== undefined) {
effectiveSignal = undefined;
effectiveProgress = undefined;
}
await checkpointJavaScriptAnalysis(effectiveSignal);
abortIfNeeded(effectiveSignal);
await reportPhase(
let interruption = cleanupFailure ?? outcome.interruption;
// Source facts are owned now. Finish their projection cooperatively so a
// later cancellation can retain Evidence instead of discarding those facts.
const checkpointCompletedAnalysis = async (): Promise<void> => {
await setImmediate();
if (interruption == null && signal?.aborted === true)
interruption = new ArtifactOperationError(OPERATION, "cancelled");
};
const reportCompletedPhase = async (
phase: string,
message: string,
): Promise<void> => {
await checkpointCompletedAnalysis();
if (interruption == null)
await progress?.report({ phase, completed: 0, total: 1, message });
await checkpointCompletedAnalysis();
};
await reportCompletedPhase(
"build_javascript_application_graph",
"Constructing application and Electron boundary relationships",
);
@@ -198,15 +208,14 @@ export const reconstructJavaScriptArtifact = async (
files,
analysis,
);
await reportPhase(
await reportCompletedPhase(
"seal_javascript_application_graph",
"Sealing the validated application graph",
);
const graph = await completeJavaScriptAnalysisSteps(
applicationGraphSteps,
effectiveSignal,
);
await reportPhase(
await reportCompletedPhase(
"build_javascript_semantic_graph",
"Binding and validating static semantic relationships",
);
@@ -214,15 +223,13 @@ export const reconstructJavaScriptArtifact = async (
snapshot.manifest.root_sha256,
graph,
);
await reportPhase(
await reportCompletedPhase(
"seal_javascript_semantic_graph",
"Validating and sealing the semantic graph",
);
const semanticGraph = await completeJavaScriptAnalysisSteps(
semanticGraphSteps,
effectiveSignal,
);
await checkpointJavaScriptAnalysis(effectiveSignal);
const semanticGraph =
await completeJavaScriptAnalysisSteps(semanticGraphSteps);
await checkpointCompletedAnalysis();
const completed = {
input_path: path,
format,
@@ -36,11 +36,30 @@ it("returns a nonzero exit for a streamed JSON analysis failure", async () => {
// `analyze` routes a JavaScript directory to the same workflow and must keep
// its typed cancellation contract.
it
.skipIf(process.platform === "win32")
.each(["analyze-javascript-application", "analyze"])(
"returns a typed cancelled CLI result and exit 130 after SIGINT through %s",
async (command) => {
it.skipIf(process.platform === "win32").each([
{
command: "analyze-javascript-application",
phase: "analyze_javascript_application",
parsedFiles: null,
},
{
command: "analyze",
phase: "analyze_javascript_application",
parsedFiles: null,
},
{
command: "analyze-javascript-application",
phase: "build_javascript_application_graph",
parsedFiles: 1,
},
{
command: "analyze-javascript-application",
phase: "hash_javascript_application_evidence",
parsedFiles: 1,
},
])(
"returns typed cancellation and exit 130 after SIGINT through $command during $phase",
async ({ command, phase, parsedFiles }) => {
const root = await createTestTempDirectory("rea-cli-js-cancellation-");
await writeFile(join(root, "main.js"), "export const observed = 1;\n");
const child = spawn(
@@ -70,7 +89,11 @@ it
const line = partial.slice(0, newline);
partial = partial.slice(newline + 1);
const parsed = progressSchema.safeParse(JSON.parse(line));
if (parsed.success && !requested) {
if (
parsed.success &&
parsed.data.rea_progress.phase === phase &&
!requested
) {
requested = true;
child.kill("SIGINT");
}
@@ -89,5 +112,18 @@ it
code: "cancelled",
details: { reason: "cancelled" },
});
if (parsedFiles !== null)
expect(JSON.parse(stdout)).toMatchObject({
details: {
partial_observation: {
normalized_result: {
statistics: {
parsed_javascript_files: parsedFiles,
parse_failures: 0,
},
},
},
},
});
},
);
@@ -1,13 +1,72 @@
import { setImmediate } from "node:timers";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { expect, it, onTestFinished } from "vitest";
import { z } from "zod";
import {
ArtifactResourceScope,
type ArtifactResourceOwner,
} from "../../../src/artifacts/ArtifactResourceScope.js";
import { analyzeJavaScriptApplication as analyzeOwnedApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
it.each(["during cleanup", "on the next control turn"] as const)(
"retains the final source when cancellation arrives %s after analysis",
async (timing) => {
const root = await createTestTempDirectory("rea-js-cancel-handoff-");
await writeFile(
join(root, "main.js"),
'import { readFile } from "node:fs/promises"; export { readFile };\n',
);
const controller = new AbortController();
class CancellingCleanupScope extends ArtifactResourceScope {
override async release(owner: ArtifactResourceOwner) {
const result = await super.release(owner);
if (owner.kind === "javascript-source-analysis") {
if (timing === "during cleanup") controller.abort();
else setImmediate(() => controller.abort());
}
return result;
}
}
const scope = new CancellingCleanupScope();
onTestFinished(() => scope.close());
const result = await analyzeOwnedApplication(
{ input_path: root, format: "directory" },
scope,
{ signal: controller.signal },
);
if (result.ok) throw new Error("Cancelled handoff must not succeed");
const projected = projectAnalysisError(result.error);
expect(projected.code).toBe("cancelled");
const partial = z
.object({
details: z.object({
partial_observation: z.object({
normalized_result: javascriptApplicationAnalysisResultSchema,
}),
}),
})
.parse(projected).details.partial_observation.normalized_result;
expect(partial.statistics).toMatchObject({
relevant_files: 1,
parsed_javascript_files: 1,
parse_failures: 0,
});
expect(partial.graph.edges).toContainEqual(
expect.objectContaining({
properties: expect.objectContaining({
specifier: "node:fs/promises",
module_link_kind: "import",
}),
}),
);
},
);
it("preserves imported manifest status when cancelled before visiting manifests", async () => {
const root = await createTestTempDirectory("rea-js-cancel-package-");
const manifests = [
@@ -75,156 +134,169 @@ it("preserves imported manifest status when cancelled before visiting manifests"
});
});
describe("JavaScript application cancellation", () => {
it("interrupts semantic graph commitment before result validation", async () => {
const root = await createTestTempDirectory("rea-js-cancel-semantic-");
await writeFile(
join(root, "main.js"),
Array.from(
{ length: 500 },
(_, index) =>
`export function inspect${index}(value) { return JSON.stringify(value); }`,
).join("\n"),
);
const controller = new AbortController();
const phases: string[] = [];
const result = await analyzeJavaScriptApplication(
{ input_path: root, format: "directory" },
{
signal: controller.signal,
progress: {
async report(event) {
phases.push(event.phase);
if (event.phase === "seal_javascript_semantic_graph")
setImmediate(() => controller.abort());
},
it("interrupts semantic graph commitment before result validation", async () => {
const root = await createTestTempDirectory("rea-js-cancel-semantic-");
await writeFile(
join(root, "main.js"),
Array.from(
{ length: 500 },
(_, index) =>
`export function inspect${index}(value) { return JSON.stringify(value); }`,
).join("\n"),
);
const controller = new AbortController();
const phases: string[] = [];
const result = await analyzeJavaScriptApplication(
{ input_path: root, format: "directory" },
{
signal: controller.signal,
progress: {
async report(event) {
phases.push(event.phase);
if (event.phase === "seal_javascript_semantic_graph")
setImmediate(() => controller.abort());
},
},
},
);
if (result.ok)
throw new Error(
"Cancelled commitment must not publish a successful analysis",
);
if (result.ok)
throw new Error(
"Cancelled commitment must not publish a successful analysis",
);
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cancelled",
details: { reason: "cancelled" },
});
expect(phases).toContain("seal_javascript_semantic_graph");
expect(phases).not.toContain("validate_javascript_application_result");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cancelled",
details: { reason: "cancelled" },
});
expect(phases).toContain("seal_javascript_semantic_graph");
expect(phases).not.toContain("validate_javascript_application_result");
});
it("honors control turns and cancellation while preparing one large source for owned analysis (#1462)", async () => {
const root = await createTestTempDirectory("rea-js-cancel-in-file-");
await writeFile(
join(root, "index.js"),
Array.from(
{ length: 1200 },
(_, index) =>
`export function f${index}(){return {name:'f${index}',value:${index}};}`,
).join("\n"),
);
const controller = new AbortController();
const phases: string[] = [];
let turns = 0;
// Count event-loop turns once the file starts; parsing, analysis and
// projection of this one file must leave room for them.
const tick = (): void => {
turns += 1;
if (turns === 3) controller.abort();
else if (!controller.signal.aborted) setImmediate(tick);
};
const result = await analyzeJavaScriptApplication(
{ input_path: root, format: "directory" },
{
signal: controller.signal,
progress: {
async report(event) {
phases.push(event.phase);
if (event.phase === "parse_javascript_source") setImmediate(tick);
},
it("honors control turns and cancellation while preparing one large source for owned analysis (#1462)", async () => {
const root = await createTestTempDirectory("rea-js-cancel-in-file-");
await writeFile(
join(root, "index.js"),
Array.from(
{ length: 1200 },
(_, index) =>
`export function f${index}(){return {name:'f${index}',value:${index}};}`,
).join("\n"),
);
const controller = new AbortController();
const phases: string[] = [];
let turns = 0;
// Count event-loop turns once the file starts; parsing, analysis and
// projection of this one file must leave room for them.
const tick = (): void => {
turns += 1;
if (turns === 3) controller.abort();
else if (!controller.signal.aborted) setImmediate(tick);
};
const result = await analyzeJavaScriptApplication(
{ input_path: root, format: "directory" },
{
signal: controller.signal,
progress: {
async report(event) {
phases.push(event.phase);
if (event.phase === "parse_javascript_source") setImmediate(tick);
},
},
);
if (result.ok) throw new Error("Cancelled analysis must not succeed");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cancelled",
});
expect(phases).toContain("parse_javascript_source");
expect(phases).not.toContain("build_javascript_application_graph");
},
);
if (result.ok) throw new Error("Cancelled analysis must not succeed");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cancelled",
});
expect(phases).toContain("parse_javascript_source");
expect(phases).not.toContain("build_javascript_application_graph");
});
it.each([
"parse_javascript_source",
"build_javascript_application_graph",
"seal_javascript_application_graph",
"build_javascript_semantic_graph",
"seal_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());
}
it.each([
"parse_javascript_source",
"build_javascript_application_graph",
"seal_javascript_application_graph",
"build_javascript_semantic_graph",
"seal_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);
if (result.ok) throw new Error("Cancelled analysis must return a failure");
const failure = projectAnalysisError(result.error);
expect(failure).toMatchObject({ code: "cancelled" });
expect(failure).toMatchObject({
details: {
operation: "analyze_javascript_application",
partial_observation: {
normalized_result: {
statistics: {
relevant_files: 1,
parsed_javascript_files:
phase === "parse_javascript_source" ? 0 : 1,
parse_failures: 0,
},
},
},
);
expect(requested).toBe(true);
if (result.ok) throw new Error("Cancelled analysis must return a failure");
const failure = projectAnalysisError(result.error);
expect(failure).toMatchObject({ code: "cancelled" });
if (phase === "parse_javascript_source")
expect(failure).toMatchObject({
details: {
operation: "analyze_javascript_application",
cleanup: "complete",
partial_observation: {
normalized_result: {
statistics: { relevant_files: 1, parse_failures: 0 },
},
},
},
});
expect(terminal).toEqual([]);
},
});
if (phase === "parse_javascript_source")
expect(failure.details).toMatchObject({ cleanup: "complete" });
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();
},
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();
},
},
);
if (result.ok) throw new Error("Expected cancellation before admission");
expect(projectAnalysisError(result.error)).toMatchObject({
details: { reason: "cancelled" },
});
},
);
if (result.ok) throw new Error("Expected cancellation before admission");
const failure = projectAnalysisError(result.error);
expect(failure).toMatchObject({
details: { reason: "cancelled" },
});
expect(failure).toMatchObject({
details: {
partial_observation: {
normalized_result: {
statistics: { relevant_files: 1, parsed_javascript_files: 1 },
},
},
},
});
});
@@ -12,128 +12,143 @@ import {
} from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it("retains completed source facts and prior Evidence after SDK cancellation, then accepts subsequent work", async () => {
const root = await createTestTempDirectory("rea-mcp-js-cancellation-");
await writeFile(join(root, "main.js"), "export const observed = 1;\n");
await writeFile(join(root, "second.js"), "export const pending = 2;\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<string>();
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);
it.each([
{ phase: "parse_javascript_source", message: "second.js", parsedFiles: 1 },
{
phase: "analyze_javascript_application",
message: "Application graph and Electron boundaries reconstructed",
parsedFiles: 2,
},
])(
"retains completed facts and prior Evidence after SDK cancellation during $phase, then accepts subsequent work",
async ({ phase, message, parsedFiles }) => {
const root = await createTestTempDirectory("rea-mcp-js-cancellation-");
await writeFile(join(root, "main.js"), "export const observed = 1;\n");
await writeFile(join(root, "second.js"), "export const pending = 2;\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 },
},
},
);
const server = createServer({ kind: "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(
{
);
expect(session.recordEvidence(prior).ok).toBe(true);
const completed = createDeferred<string>();
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({ kind: "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(update) {
if (
!update.message?.startsWith(`${phase}:`) ||
!update.message.includes(message)
)
return;
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: {},
});
const records = z
.object({
result: z.object({
records: z.array(z.object({ evidence_id: z.string() })),
}),
})
.parse(bundle.structuredContent).result.records;
expect(records).toContainEqual({ evidence_id: prior.evidence_id });
const partial = records.find(
({ evidence_id }) => evidence_id !== prior.evidence_id,
);
if (partial === undefined)
throw new Error(
"Cancelled analysis discarded its completed source facts",
);
expect(
z
.object({
statistics: z.object({
parsed_javascript_files: z.number(),
parse_failures: z.number(),
}),
})
.parse(session.evidenceById(partial.evidence_id)?.normalized_result)
.statistics,
).toEqual({ parsed_javascript_files: parsedFiles, parse_failures: 0 });
const view = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source: { kind: "retained-evidence", evidence_id: partial.evidence_id },
view: { kind: "summary" },
},
});
expect(view.isError, JSON.stringify(view)).not.toBe(true);
const next = await client.callTool({
name: "analyze_javascript_application",
arguments: {
input_path: root,
format: "directory",
},
},
{
signal: controller.signal,
onprogress(update) {
if (
!update.message?.startsWith("parse_javascript_source:") ||
!update.message.includes("second.js")
)
return;
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: {},
});
const records = z
.object({
result: z.object({
records: z.array(z.object({ evidence_id: z.string() })),
}),
})
.parse(bundle.structuredContent).result.records;
expect(records).toContainEqual({ evidence_id: prior.evidence_id });
const partial = records.find(
({ evidence_id }) => evidence_id !== prior.evidence_id,
);
if (partial === undefined)
throw new Error("Cancelled analysis discarded its completed source facts");
expect(
z
.object({
statistics: z.object({
parsed_javascript_files: z.number(),
parse_failures: z.number(),
}),
})
.parse(session.evidenceById(partial.evidence_id)?.normalized_result)
.statistics,
).toEqual({ parsed_javascript_files: 1, parse_failures: 0 });
const view = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source: { kind: "retained-evidence", evidence_id: partial.evidence_id },
view: { kind: "summary" },
},
});
expect(view.isError, JSON.stringify(view)).not.toBe(true);
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();
});
});
expect(next.isError, JSON.stringify(next)).not.toBe(true);
await client.ping();
},
);