fix(javascript): preserve failure provenance and cleanup ownership

This commit is contained in:
morluto committed 2026-10-23 00:25:26 +08:00
1 parent 957bcfa149
commit 566a9a1551
7 files changed
+175 -69

No files matched your search

@@ -5,6 +5,7 @@ import { completeJavaScriptAnalysisSteps } from "./JavaScriptAnalysisControl.js"
describe("JavaScript cooperative computation lifecycle", () => {
it("observes scheduler cancellation and closes abandoned work", async () => {
const controller = new AbortController();
const reason = { request: "caller cancelled computation" };
let released = false;
function* work(): Generator<void, number> {
try {
@@ -13,17 +14,18 @@ describe("JavaScript cooperative computation lifecycle", () => {
released = true;
}
}
const cancellation = setImmediate().then(() => controller.abort());
const cancellation = setImmediate().then(() => controller.abort(reason));
await expect(
completeJavaScriptAnalysisSteps(work(), controller.signal),
).rejects.toMatchObject({ reason: "cancelled" });
).rejects.toMatchObject({ reason: "cancelled", cause: reason });
await cancellation;
expect(released).toBe(true);
});
it("does not start pre-cancelled work and preserves completed values", async () => {
const controller = new AbortController();
controller.abort();
const reason = { request: "caller cancelled computation" };
controller.abort(reason);
let started = false;
function* work(): Generator<void, number> {
started = true;
@@ -32,7 +34,7 @@ describe("JavaScript cooperative computation lifecycle", () => {
}
await expect(
completeJavaScriptAnalysisSteps(work(), controller.signal),
).rejects.toMatchObject({ reason: "cancelled" });
).rejects.toMatchObject({ reason: "cancelled", cause: reason });
expect(started).toBe(false);
expect(await completeJavaScriptAnalysisSteps(work())).toBe(42);
});
@@ -7,6 +7,7 @@ export const assertJavaScriptAnalysisActive = (signal?: AbortSignal): void => {
throw new ArtifactReaderFailure(
"cancelled",
"JavaScript application analysis cancelled",
{ cause: signal.reason },
);
};
@@ -5,6 +5,7 @@ import {
import { z } from "zod";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { translateArtifactFailure } from "../../artifacts/ArtifactOperationFailure.js";
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import {
analyzeJavaScriptApplicationInputSchema,
@@ -144,7 +145,7 @@ export const analyzeJavaScriptApplicationValidated = async (
result,
);
return err(
artifactFailureToOperationError(cause).retainPartialObservation(
translateArtifactFailure(OPERATION, cause).retainPartialObservation(
evidence,
),
);
@@ -157,7 +158,7 @@ export const analyzeJavaScriptApplicationValidated = async (
// Preserve execution constraints and their completed partial Evidence.
if (cause instanceof AnalysisError) return err(cause);
if (cause instanceof ArtifactReaderFailure)
return err(artifactFailureToOperationError(cause));
return err(translateArtifactFailure(OPERATION, cause));
if (cause instanceof z.ZodError)
return err(
new AnalysisOutputError(OPERATION, describeResultSchemaFailure(cause), {
@@ -189,24 +190,6 @@ export const analyzeJavaScriptApplicationValidated = async (
}
};
const artifactFailureToOperationError = (
cause: ArtifactReaderFailure,
): ArtifactOperationError =>
new ArtifactOperationError(
OPERATION,
cause.reason,
cause.details,
cause.message,
{
cause,
...(cause.cleanup === undefined ? {} : { cleanup: cause.cleanup }),
...(cause.partialObservation === undefined
? {}
: { partialObservation: cause.partialObservation }),
...(cause.admission === undefined ? {} : { admission: cause.admission }),
},
);
const describeResultSchemaFailure = (cause: z.ZodError): string => {
const issue = cause.issues[0];
if (issue === undefined) return "Result schema rejected an unknown issue";
@@ -43,6 +43,8 @@ import { htmlArtifactReferences } from "../../domain/javascript/htmlArtifactRefe
import type { JavaScriptSourceAnalysisPort } from "../../domain/javascript/javascriptSourceAnalysis.js";
import type { JavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { translateArtifactFailure } from "../../artifacts/ArtifactOperationFailure.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import {
AnalysisCancelledError,
@@ -153,13 +155,16 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async (
if (signal?.aborted === true) {
interruption = new AnalysisCancelledError(
"analyze_javascript_application",
{ cause: signal.reason },
);
break;
}
continue;
}
if (signal?.aborted === true)
throw new AnalysisCancelledError("analyze_javascript_application");
throw new AnalysisCancelledError("analyze_javascript_application", {
cause: signal.reason,
});
await beforeFile(file, selectedFileIndex++, selectedFileCount);
analysisStarted = true;
if (file.kind !== "javascript" || !file.text.included) {
@@ -245,39 +250,44 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async (
break;
}
} catch (cause: unknown) {
if (signal?.aborted !== true && !(cause instanceof AnalysisError))
throw cause;
interruption =
signal?.aborted === true
? new AnalysisCancelledError("analyze_javascript_application")
: cause instanceof AnalysisError
? cause
: null;
if (interruption !== null) {
const scope = javascriptArtifactAnalysisScopes[file.kind];
state.files.push({
file,
javascript: null,
semantic: null,
...(scope === null
? {}
: {
analysis_failure: {
scope,
status: analysisStarted ? "interrupted" : "not-started",
reason: `${file.path}: ${interruption.message}`,
error: projectAnalysisError(interruption),
limits: null,
},
}),
});
projection.recordUnavailableFile(
file,
interruption.message,
null,
"incomplete-module",
if (cause instanceof AnalysisError) interruption = cause;
else if (
cause instanceof ArtifactReaderFailure &&
cause.reason === "cancelled"
)
interruption = translateArtifactFailure(
"analyze_javascript_application",
cause,
);
}
else if (signal?.aborted === true && cause === signal.reason)
interruption = new AnalysisCancelledError(
"analyze_javascript_application",
{ cause },
);
else throw cause;
const scope = javascriptArtifactAnalysisScopes[file.kind];
state.files.push({
file,
javascript: null,
semantic: null,
...(scope === null
? {}
: {
analysis_failure: {
scope,
status: analysisStarted ? "interrupted" : "not-started",
reason: `${file.path}: ${interruption.message}`,
error: projectAnalysisError(interruption),
limits: null,
},
}),
});
projection.recordUnavailableFile(
file,
interruption.message,
null,
"incomplete-module",
);
break;
}
}
@@ -227,7 +227,13 @@ export const reconstructJavaScriptArtifact = async (
const checkpointCompletedAnalysis = async (): Promise<void> => {
await setImmediate();
if (interruption == null && signal?.aborted === true)
interruption = new ArtifactOperationError(OPERATION, "cancelled");
interruption = new ArtifactOperationError(
OPERATION,
"cancelled",
undefined,
undefined,
{ cause: signal.reason },
);
};
const reportCompletedPhase = async (
phase: string,
@@ -30,6 +30,7 @@ describe("JavaScript source selection cancellation boundary", () => {
const snapshot = await scanCanonicalArtifactInventory(root, {});
const reader = createJavaScriptArtifactReader(root, "directory");
const controller = new AbortController();
const reason = { request: "caller cancelled selected sources" };
const sourcePaths = ["main.mjs"];
try {
const files = await readJavaScriptArtifactFiles(
@@ -52,7 +53,7 @@ describe("JavaScript source selection cancellation boundary", () => {
{
analyze: async (...args) => {
const result = await realWorker(...args);
controller.abort();
controller.abort(reason);
return result;
},
},
@@ -64,6 +65,7 @@ describe("JavaScript source selection cancellation boundary", () => {
await worker.close();
}
expect(outcome.interruption).toBeInstanceOf(AnalysisCancelledError);
expect(outcome.interruption?.cause).toBe(reason);
const unselected = outcome.analysis.files.filter(
({ file }) =>
!file.text.included && file.text.reason === "outside-analysis-scope",
@@ -151,6 +153,7 @@ describe("Selected JavaScript source failure diagnostics", () => {
await writeJavaScriptSourceSelectionFixture(root);
await writeFile(join(root, "z-later.mjs"), "export const later = 1;\n");
const controller = new AbortController();
const reason = { request: "caller cancelled selected sources" };
const source_paths = ["main.mjs", "z-later.mjs"];
const result = await analyzeJavaScriptApplication(
{ input_path: root, source_paths },
@@ -162,12 +165,13 @@ describe("Selected JavaScript source failure diagnostics", () => {
event.phase === "parse_javascript_source" &&
event.message.includes("z-later.mjs")
)
controller.abort();
controller.abort(reason);
},
},
},
);
if (result.ok) throw new Error("Expected selected-source cancellation");
expect(result.error.cause).toBe(reason);
expect(projectAnalysisError(result.error).code).toBe("cancelled");
const evidence = parseEvidence(result.error.partialObservation);
const partial = javascriptApplicationAnalysisResultSchema.parse(
@@ -1,5 +1,5 @@
import { setImmediate } from "node:timers";
import { mkdir, realpath, symlink, writeFile } from "node:fs/promises";
import { mkdir, open, realpath, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished } from "vitest";
import { z } from "zod";
@@ -11,6 +11,7 @@ import { analyzeJavaScriptApplication as analyzeOwnedApplication } from "../../.
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { AnalysisOutputError } from "../../../src/domain/analysisErrorCore.js";
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
it.each(["during cleanup", "on the next control turn"] as const)(
@@ -31,12 +32,13 @@ it.each(["during cleanup", "on the next control turn"] as const)(
'import { readFile } from "node:fs/promises"; export { readFile };\n',
);
const controller = new AbortController();
const reason = { request: "caller cancelled JavaScript analysis" };
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());
if (timing === "during cleanup") controller.abort(reason);
else setImmediate(() => controller.abort(reason));
}
return result;
}
@@ -49,6 +51,7 @@ it.each(["during cleanup", "on the next control turn"] as const)(
{ signal: controller.signal },
);
if (result.ok) throw new Error("Cancelled handoff must not succeed");
expect(result.error.cause).toBe(reason);
const projected = projectAnalysisError(result.error);
expect(projected.code).toBe("cancelled");
expect(projected).toMatchObject({
@@ -111,6 +114,7 @@ it("preserves imported manifest status when cancelled before visiting manifests"
);
await writeFile(join(root, "b.cjs"), "module.exports = 1;");
const controller = new AbortController();
const reason = { request: "caller cancelled JavaScript analysis" };
const visited: string[] = [];
const result = await analyzeJavaScriptApplication(
{ input_path: root, format: "directory" },
@@ -120,12 +124,13 @@ it("preserves imported manifest status when cancelled before visiting manifests"
async report(event) {
if (event.phase !== "parse_javascript_source") return;
visited.push(event.message);
if (event.message.includes("b.cjs")) controller.abort();
if (event.message.includes("b.cjs")) controller.abort(reason);
},
},
},
);
if (result.ok) throw new Error("Cancelled analysis must return a failure");
expect(result.error.cause).toBe(reason);
const failure = projectAnalysisError(result.error);
expect(failure.code).toBe("cancelled");
expect(visited).toHaveLength(2);
@@ -171,6 +176,7 @@ it("keeps cancellation before native metadata out of AST scopes and semantic mod
).map(([path, content]) => writeFile(join(root, path), content)),
);
const controller = new AbortController();
const reason = { request: "caller cancelled JavaScript analysis" };
let reachedNativeMetadata = false;
const result = await analyzeJavaScriptApplication(
{ input_path: root, format: "directory" },
@@ -183,7 +189,7 @@ it("keeps cancellation before native metadata out of AST scopes and semantic mod
event.message.includes("b.node")
) {
reachedNativeMetadata = true;
controller.abort();
controller.abort(reason);
}
},
},
@@ -191,6 +197,7 @@ it("keeps cancellation before native metadata out of AST scopes and semantic mod
);
expect(reachedNativeMetadata).toBe(true);
if (result.ok) throw new Error("Cancelled analysis must return a failure");
expect(result.error.cause).toBe(reason);
const partial = z
.object({
code: z.literal("cancelled"),
@@ -261,6 +268,7 @@ it("interrupts semantic graph commitment before result validation", async () =>
).join("\n"),
);
const controller = new AbortController();
const reason = { request: "caller cancelled JavaScript analysis" };
const phases: string[] = [];
const result = await analyzeJavaScriptApplication(
{ input_path: root, format: "directory" },
@@ -270,7 +278,7 @@ it("interrupts semantic graph commitment before result validation", async () =>
async report(event) {
phases.push(event.phase);
if (event.phase === "seal_javascript_semantic_graph")
setImmediate(() => controller.abort());
setImmediate(() => controller.abort(reason));
},
},
},
@@ -298,13 +306,14 @@ it("honors control turns and cancellation while preparing one large source for o
).join("\n"),
);
const controller = new AbortController();
const reason = { request: "caller cancelled JavaScript analysis" };
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();
if (turns === 3) controller.abort(reason);
else if (!controller.signal.aborted) setImmediate(tick);
};
const result = await analyzeJavaScriptApplication(
@@ -345,6 +354,7 @@ it.each([
"export function inspect(value) { return JSON.stringify(value); }\n",
);
const controller = new AbortController();
const reason = { request: "caller cancelled JavaScript analysis" };
let requested = false;
const terminal: string[] = [];
const result = await analyzeJavaScriptApplication(
@@ -356,7 +366,7 @@ it.each([
if (event.terminal) terminal.push(event.phase);
if (event.phase === phase) {
requested = true;
setImmediate(() => controller.abort());
setImmediate(() => controller.abort(reason));
}
},
},
@@ -364,6 +374,7 @@ it.each([
);
expect(requested).toBe(true);
if (result.ok) throw new Error("Cancelled analysis must return a failure");
expect(result.error.cause).toBe(reason);
const failure = projectAnalysisError(result.error);
expect(failure).toMatchObject({ code: "cancelled" });
expect(failure).toMatchObject({
@@ -390,18 +401,20 @@ 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 reason = { request: "caller cancelled JavaScript analysis" };
const result = await analyzeJavaScriptApplication(
{ input_path: root, format: "directory" },
{
signal: controller.signal,
progress: {
async report(event) {
if (event.terminal) controller.abort();
if (event.terminal) controller.abort(reason);
},
},
},
);
if (result.ok) throw new Error("Expected cancellation before admission");
expect(result.error.cause).toBe(reason);
const failure = projectAnalysisError(result.error);
expect(failure).toMatchObject({
details: { reason: "cancelled" },
@@ -416,3 +429,90 @@ it("honors cancellation observed during the terminal publication race", async ()
},
});
});
it("keeps the exact retained descriptor owner through application failure translation and retry", async () => {
const root = await createTestTempDirectory("rea-js-retained-cleanup-");
const paths = [join(root, "first.js"), join(root, "second.js")];
await Promise.all(
paths.map((path) => writeFile(path, "export const value = 1;")),
);
const files = await Promise.all(paths.map((path) => open(path, "r")));
const scope = new ArtifactResourceScope();
let allowClose = false;
const attempts = [0, 0];
files.forEach((file, index) =>
scope.retain({
kind: "lease",
resource: paths[index]!,
lease: {
async release() {
attempts[index]! += 1;
if (index === 0 && !allowClose)
throw new Error("injected descriptor close denial");
await file.close();
},
},
}),
);
onTestFinished(async () => {
allowClose = true;
await scope.close();
});
const result = await analyzeOwnedApplication(
{ input_path: root, format: "directory" },
scope,
);
if (result.ok) throw new Error("Expected retained cleanup to block analysis");
expect(result.error.cleanupIncomplete).toBe(true);
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cleanup_incomplete",
retryable: false,
});
expect(attempts).toEqual([1, 1]);
allowClose = true;
await scope.close();
expect(result.error.cleanupIncomplete).toBe(false);
expect(attempts).toEqual([2, 1]);
});
it.each(["native-error", "analysis-error", "abort-reason"] as const)(
"classifies the producer progress failure under late cancellation (%s)",
async (kind) => {
const root = await createTestTempDirectory("rea-js-progress-primary-");
await writeFile(join(root, "main.js"), "export const observed = 1;");
const controller = new AbortController();
const reason = { request: "unrelated cancellation" };
const failure =
kind === "abort-reason"
? reason
: kind === "analysis-error"
? new AnalysisOutputError(
"analyze_javascript_application",
"progress callback failed",
)
: new Error("progress callback failed");
const result = await analyzeJavaScriptApplication(
{ input_path: root, format: "directory" },
{
signal: controller.signal,
progress: {
async report(event) {
if (event.phase === "parse_javascript_source") {
controller.abort(reason);
throw failure;
}
},
},
},
);
if (result.ok) throw new Error("Expected progress failure");
if (kind === "abort-reason") {
expect(projectAnalysisError(result.error).code).toBe("cancelled");
expect(result.error.cause).toBe(reason);
} else if (kind === "analysis-error") expect(result.error).toBe(failure);
else {
expect(result.error).toMatchObject({ _tag: "ProviderAdapterError" });
expect(result.error.cause).toBe(failure);
}
},
);