mirror of
https://github.com/morluto/rea
synced 2026-10-27 06:33:32 -04:00
fix(javascript): honor cancellation before evidence publication (#1098)
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user