fix(javascript): honor cancellation before evidence publication (#1098)

This commit is contained in:
Ysopwnable
2026-10-09 01:15:50 +08:00
committed by GitHub
parent f8e143a3bc
commit cc0ee2edc6
18 changed files with 1080 additions and 74 deletions
@@ -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<void, number> {
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<void, number> {
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);
});
});
@@ -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<void> => {
assertJavaScriptAnalysisActive(signal);
await setImmediate();
assertJavaScriptAnalysisActive(signal);
};
/** Drive bounded owned-domain steps while permitting cancellation and control I/O. */
export const completeJavaScriptAnalysisSteps = async <Value>(
steps: Iterator<void, Value, void>,
signal?: AbortSignal,
): Promise<Value> => {
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?.();
}
};
@@ -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" },
});
});
});
@@ -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<Evidence> => {
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<void> =>
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"] => ({
@@ -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<typeof analyzeJavaScriptApplicationInputSchema>,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
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)
@@ -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<JavaScriptArtifactReconstructionResult> => {
const reportPhase = async (phase: string, message: string): Promise<void> => {
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,