mirror of
https://github.com/morluto/rea.git
synced 2026-10-29 19:05:45 +00:00
fix(javascript): retain completed facts through cancellation (#1934)
This commit is contained in:
1 parent
0b2aa69531
commit
b6651cb99f
6 files changed
+465
-295
No files matched your search
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user