From 5f21aeeeb386f23fe688e2123b811adaca3ba9dc Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:56:52 +0800 Subject: [PATCH] fix(javascript): retain worker storage and projection failures --- docs/javascript-artifact-reconstruction.md | 6 + .../analysis/JavaScriptAnalysisWorker.ts | 15 +- .../workerFailureComposition.test.ts | 166 ++++++++++++++++++ .../javascript/workerOrderAudit.test.ts | 22 +-- tests/fixtures/javascriptWorkerCheckpoint.ts | 22 +++ 5 files changed, 209 insertions(+), 22 deletions(-) create mode 100644 tests/boundary/javascript/workerFailureComposition.test.ts create mode 100644 tests/fixtures/javascriptWorkerCheckpoint.ts diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index f11c68102d..a19427344b 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -220,6 +220,12 @@ to find the record. CLI failure output contains the complete partial Evidence. Owned analyzer cleanup is verified before private files are released; uncertain cleanup retains ownership for retry and is reported separately. +Private worker storage failures retain the source identity, worker phase and +reported I/O message as execution failures; they are distinct from malformed +worker output. A per-source temporary-file cleanup failure preserves static and +module facts together with any parent application-projection limit. Cleanup +failure does not authorize expanding relationships that parent admission declined. + ## What is reconstructed The projector reuses the content-addressed artifact inventory and safe artifact diff --git a/src/javascript/analysis/JavaScriptAnalysisWorker.ts b/src/javascript/analysis/JavaScriptAnalysisWorker.ts index 6b11b1edde..f337d82452 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorker.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorker.ts @@ -539,7 +539,7 @@ export class JavaScriptAnalysisWorker { this.#runtime.retainForCleanup(); const error = this.#cleanupError(message(cause)); this.#runtime.bindCleanupFailure(error); - return err({ + result = err({ javascript, module, projection, @@ -1037,6 +1037,19 @@ export class JavaScriptAnalysisWorker { response: Extract, phase: string, ): AnalysisError { + if (response.reason === "io") + return new ProviderAdapterError(PROVIDER, OPERATION, { + capturedOutput: this.#captured(), + userMessage: `Owned JavaScript worker storage failed for ${path} during ${phase}. Retained observations and cleanup status are reported separately.`, + remediationAction: + "Check the availability and permissions of REA's private temporary storage, correct the reported I/O failure, and retry. Changing the selected source is not required by this storage failure.", + diagnostics: { + source_path: path, + phase, + worker_failure_reason: response.reason, + message: response.message, + }, + }); if (response.reason === "resource") { const reported = response.limits; const limits = { ...this.#limits(), ...reported }; diff --git a/tests/boundary/javascript/workerFailureComposition.test.ts b/tests/boundary/javascript/workerFailureComposition.test.ts new file mode 100644 index 0000000000..5923a835f1 --- /dev/null +++ b/tests/boundary/javascript/workerFailureComposition.test.ts @@ -0,0 +1,166 @@ +import { createHash } from "node:crypto"; +import { afterEach, expect, it, vi } from "vitest"; + +const faults = vi.hoisted(() => ({ + lowHeadroom: false, + sourceRemoval: false, + removals: 0, +})); + +vi.mock("node:v8", async (original) => { + const actual = await original(); + return { + ...actual, + getHeapStatistics: () => { + const observed = actual.getHeapStatistics(); + return faults.lowHeadroom + ? { + ...observed, + heap_size_limit: + observed.used_heap_size + 192 * 1024 * 1024 + 128 * 1024, + } + : observed; + }, + }; +}); + +vi.mock("node:fs/promises", async (original) => { + const actual = await original(); + return { + ...actual, + rm: async (...args: Parameters) => { + if ( + faults.sourceRemoval && + String(args[0]).includes("rea-javascript-analysis-") && + String(args[0]).endsWith(".source") + ) { + faults.removals += 1; + throw Object.assign( + new Error("controlled source-file removal failure"), + { code: "EACCES" }, + ); + } + return actual.rm(...args); + }, + }; +}); + +import { JavaScriptAnalysisWorker } from "../../../src/javascript/analysis/JavaScriptAnalysisWorker.js"; +import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js"; +import { staticCheckpointFacts } from "../../fixtures/javascriptWorkerCheckpoint.js"; +import { javaScriptWorkerStaticAnalysisSchema } from "../../../src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.js"; + +const text = "export const observed = 1;"; +const file: JavaScriptArtifactFile = { + path: "app.mjs", + sha256: createHash("sha256").update(text).digest("hex"), + container_sha256: "1".repeat(64), + bytes: Buffer.byteLength(text), + inventory_artifact_id: "fixture-source", + kind: "javascript", + unpacked: false, + text: { included: true, value: text }, +}; +const ready = + 'process.stdout.write(JSON.stringify({kind:"ready",heap_limit_bytes:require("node:v8").getHeapStatistics().heap_size_limit})+"\\n");'; + +afterEach(() => { + faults.lowHeadroom = false; + faults.sourceRemoval = false; + faults.removals = 0; +}); + +it.each(["io", "output"] as const)( + "keeps worker %s failure classification distinct at the protocol boundary", + async (reason) => { + const worker = new JavaScriptAnalysisWorker( + { heapMb: 128, timeoutMs: 30_000 }, + async (request) => + spawnOwnedProviderProcess({ + ...request, + arguments: [ + "-e", + `${ready} require("node:readline").createInterface({input:process.stdin}).on("line", line => { const request=JSON.parse(line); const response={kind:"failure",id:request.id,reason:${JSON.stringify(reason)},resource:null,limits:null,message:"ENOENT: private source copy unavailable"}; process.stdout.write(JSON.stringify(response)+"\\n"); });`, + ], + }), + ); + try { + const result = await worker.analyze(file, { nodes: 100, unknowns: 100 }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected worker failure"); + expect(result.error.error._tag).toBe( + reason === "io" ? "ProviderAdapterError" : "AnalysisOutputError", + ); + const projected = projectAnalysisError(result.error.error); + expect(projected.code).toBe( + reason === "io" ? "execution_failure" : "unreadable_output", + ); + if (reason === "io") { + expect(JSON.stringify(projected)).toContain( + "ENOENT: private source copy unavailable", + ); + expect(JSON.stringify(projected)).toContain( + '"phase":"source-transfer"', + ); + expect(projected.message).toContain("storage failed"); + } + } finally { + await worker.close(); + } + }, +); + +it.each([false, true])( + "retains parent projection admission across per-source cleanup failure: %s", + async (removalFailure) => { + faults.lowHeadroom = true; + faults.sourceRemoval = removalFailure; + const sourceMapUrls = [ + { + declared_url: "app.map", + location: { + start: { line: 1, column: 0 }, + end: { line: 1, column: 1 }, + }, + }, + ]; + const checkpoint = JSON.stringify( + javaScriptWorkerStaticAnalysisSchema.parse({ + ...staticCheckpointFacts, + source_map_urls: sourceMapUrls, + }), + ); + const worker = new JavaScriptAnalysisWorker( + { heapMb: 128, timeoutMs: 30_000 }, + async (request) => + spawnOwnedProviderProcess({ + ...request, + arguments: [ + "-e", + `${ready} const fs=require("node:fs"); const {createHash}=require("node:crypto"); const checkpoint=${JSON.stringify(checkpoint)}; require("node:readline").createInterface({input:process.stdin}).on("line", line => { const request=JSON.parse(line); if(request.file) {fs.writeFileSync(request.id+".static.json",checkpoint); process.stdout.write(JSON.stringify({kind:"static",id:request.id,bytes:Buffer.byteLength(checkpoint),sha256:createHash("sha256").update(checkpoint).digest("hex")})+"\\n");} else process.stdout.write(JSON.stringify({kind:"complete",id:request.id,descriptor:null})+"\\n"); });`, + ], + }), + ); + try { + const result = await worker.analyze(file, { nodes: 100, unknowns: 100 }); + expect(result.ok).toBe(!removalFailure); + const retained = result.ok ? result.value : result.error; + expect(retained.javascript?.source_map_urls).toEqual(sourceMapUrls); + expect(retained.applicationProjectionFailure).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + reason: expect.stringContaining("Expanded application relationships"), + }); + if (!result.ok) + expect(result.error.error).toMatchObject({ + _tag: "ProviderCleanupError", + cleanupIncomplete: true, + }); + expect(faults.removals).toBe(removalFailure ? 1 : 0); + } finally { + faults.sourceRemoval = false; + await worker.close(); + } + }, +); diff --git a/tests/boundary/javascript/workerOrderAudit.test.ts b/tests/boundary/javascript/workerOrderAudit.test.ts index 86e1c45b97..a32896d5c6 100644 --- a/tests/boundary/javascript/workerOrderAudit.test.ts +++ b/tests/boundary/javascript/workerOrderAudit.test.ts @@ -18,6 +18,7 @@ import { } from "../../../src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.js"; import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js"; +import { staticCheckpointFacts } from "../../fixtures/javascriptWorkerCheckpoint.js"; const observeWorkerFrames = ( stdout: Readable | null | undefined, @@ -76,27 +77,6 @@ const ready = 'process.stdout.write(JSON.stringify({kind:"ready",heap_limit_bytes:require("node:v8").getHeapStatistics().heap_size_limit})+"\\n");'; const phaseAfterRequest = 'process.stdin.once("data", chunk => { const request=JSON.parse(String(chunk)); process.stdout.write(JSON.stringify({kind:"phase",id:request.id,phase:"parse"})+"\\n");'; -const staticCheckpointFacts = { - parse_status: "complete", - parse_error_count: 0, - visited_ast_nodes: 1, - references: [], - endpoints: [], - storage: [], - bundler_registrations: [], - role_paths: [], - source_map_urls: [], - vendors: [], - electron: { - browser_windows: [], - context_bridge_apis: [], - ipc: [], - sender_validations: [], - utility_processes: [], - native_addon_bindings: [], - }, - limitations: [], -}; it.each([ { diff --git a/tests/fixtures/javascriptWorkerCheckpoint.ts b/tests/fixtures/javascriptWorkerCheckpoint.ts new file mode 100644 index 0000000000..385af4f4b2 --- /dev/null +++ b/tests/fixtures/javascriptWorkerCheckpoint.ts @@ -0,0 +1,22 @@ +/** Minimal complete static checkpoint shared by worker protocol fixtures. */ +export const staticCheckpointFacts = { + parse_status: "complete", + parse_error_count: 0, + visited_ast_nodes: 1, + references: [], + endpoints: [], + storage: [], + bundler_registrations: [], + role_paths: [], + source_map_urls: [], + vendors: [], + electron: { + browser_windows: [], + context_bridge_apis: [], + ipc: [], + sender_validations: [], + utility_processes: [], + native_addon_bindings: [], + }, + limitations: [], +};