From 321cacb84a020f447e1110df5c58fa71d99b7bf1 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:43:31 +0800 Subject: [PATCH] fix(wasm): retain cancellation through final cleanup Observe cancellation after releasing the private runtime root and preserve completed Evidence. Keep cleanup failure, cancellation and retry ownership separate. Construct partial Evidence only when the outcome needs it. Validate both successful cleanup and abort plus cleanup failure/retry. Type the scenario tag map and give cleanup assertions live counters to prevent missing-tag and stale-counter test debt. Tests use simulated WABT executables. Refs #4254. --- src/wasm/WabtArtifactProvider.test.ts | 121 ++++++++++++++++++++------ src/wasm/WabtArtifactProvider.ts | 49 +++++++---- 2 files changed, 127 insertions(+), 43 deletions(-) diff --git a/src/wasm/WabtArtifactProvider.test.ts b/src/wasm/WabtArtifactProvider.test.ts index 206e9ac731..25fe730f77 100644 --- a/src/wasm/WabtArtifactProvider.test.ts +++ b/src/wasm/WabtArtifactProvider.test.ts @@ -40,10 +40,27 @@ vi.mock("node:fs/promises", async (importOriginal) => { }; }); -const failureTags: Readonly> = { +const outcomeModes = [ + "success", + "invalid", + "malformed", + "timeout", + "cancelled", + "cancelled-close", + "cancelled-close-failure", + "cleanup", + "changed-snapshot", + "malformed-wat", + "wat-warning", +] as const; +type OutcomeMode = (typeof outcomeModes)[number]; + +const failureTags: Readonly, string>> = { invalid: "AnalysisInputError", timeout: "AnalysisTimeoutError", cancelled: "AnalysisCancelledError", + "cancelled-close": "AnalysisCancelledError", + "cancelled-close-failure": "ProviderAdapterError", "changed-snapshot": "AnalysisArtifactChangedError", cleanup: "ProviderAdapterError", malformed: "AnalysisOutputError", @@ -88,8 +105,60 @@ const assertCleanupRecovery = async (options: { }); }; +const assertCancellationDuringRootRelease = async (options: { + readonly mode: "cancelled-close" | "cancelled-close-failure"; + readonly result: Awaited>; + readonly provider: WabtArtifactProvider; + readonly runtimePath: string; + readonly cleanupAttempts: () => number; +}): Promise => { + if (options.mode === "cancelled-close") { + expect(options.result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + partialObservation: { normalized_result: { validation: "valid" } }, + cleanupIncomplete: false, + }, + }); + return; + } + expect(options.result).toMatchObject({ + ok: false, + error: { + _tag: "ProviderAdapterError", + cleanupIncomplete: true, + cleanupResources: [options.runtimePath], + partialObservation: { normalized_result: { validation: "valid" } }, + diagnostics: { previous_error: { code: "cancelled" } }, + }, + }); + expect(options.cleanupAttempts()).toBe(1); + await expect(access(options.runtimePath)).resolves.toBeUndefined(); + await options.provider.close(); + expect(options.cleanupAttempts()).toBe(2); +}; + +const createWabtArtifactFixture = async () => { + const selectedRoot = await createTestTempDirectory("rea-wabt-seam-"); + const path = join(selectedRoot, "selected.wasm"); + const bytes = Buffer.alloc(76, 1); + await writeFile(path, bytes); + for (const tool of ["wasm-validate", "wasm-objdump", "wasm2wat"]) + await writeFile( + join(selectedRoot, tool + (process.platform === "win32" ? ".exe" : "")), + "source-owned executable seam", + { mode: 0o700 }, + ); + const dump = await readFile( + new URL("../../tests/fixtures/wasm/objdump.txt", import.meta.url), + "utf8", + ); + return { selectedRoot, path, bytes, dump }; +}; + const assertModeOutcome = ( - mode: string, + mode: OutcomeMode, result: Awaited>, path: string, ): void => { @@ -307,31 +376,8 @@ const assertGlueObservations = ( } }; -it.each([ - "success", - "invalid", - "malformed", - "timeout", - "cancelled", - "cleanup", - "changed-snapshot", - "malformed-wat", - "wat-warning", -])("owns private artifact bytes and retains %s outcome", async (mode) => { - const selectedRoot = await createTestTempDirectory("rea-wabt-seam-"); - const path = join(selectedRoot, "selected.wasm"); - const bytes = Buffer.alloc(76, 1); - await writeFile(path, bytes); - for (const tool of ["wasm-validate", "wasm-objdump", "wasm2wat"]) - await writeFile( - join(selectedRoot, tool + (process.platform === "win32" ? ".exe" : "")), - "source-owned executable seam", - { mode: 0o700 }, - ); - const dump = await readFile( - new URL("../../tests/fixtures/wasm/objdump.txt", import.meta.url), - "utf8", - ); +const runOwnedArtifactOutcome = async (mode: OutcomeMode): Promise => { + const { selectedRoot, path, bytes, dump } = await createWabtArtifactFixture(); let runtimePath = ""; let runtimeCreations = 0; let acquiredPid: number | undefined; @@ -400,9 +446,15 @@ it.each([ return { path: root.path, close: async () => { + if (mode === "cancelled-close-failure") { + controller.abort(); + if (++cleanupAttempts === 1) + throw new Error("Source-owned cleanup uncertainty"); + } if (mode === "cleanup" && ++cleanupAttempts <= 2) throw new Error("Source-owned cleanup uncertainty"); await root.close(); + if (mode === "cancelled-close") controller.abort(); }, }; }, @@ -457,13 +509,26 @@ it.each([ runtimeCreations: () => runtimeCreations, }); } + if (mode === "cancelled-close" || mode === "cancelled-close-failure") + await assertCancellationDuringRootRelease({ + mode, + result, + provider, + runtimePath, + cleanupAttempts: () => cleanupAttempts, + }); await expect(access(runtimePath)).rejects.toMatchObject({ code: "ENOENT" }); expect(await readFile(path)).toEqual(bytes); if (acquiredPid !== undefined) { const pid = acquiredPid; expect(() => process.kill(pid, 0)).toThrow(); } -}); +}; + +it.each(outcomeModes)( + "owns private artifact bytes and retains %s outcome", + runOwnedArtifactOutcome, +); const outputLimitFailure = ({ stdout = "(module", stderr = "", diff --git a/src/wasm/WabtArtifactProvider.ts b/src/wasm/WabtArtifactProvider.ts index fae455c244..af4a7dc310 100644 --- a/src/wasm/WabtArtifactProvider.ts +++ b/src/wasm/WabtArtifactProvider.ts @@ -448,21 +448,40 @@ export class WabtArtifactProvider implements WasmArtifactPort { resource: root.path, root, }); + let partialObservation = result.ok + ? undefined + : result.error.partialObservation; + if ( + result.ok && + (options?.signal?.aborted || cleanup.kind === "failed") + ) { + partialObservation = createEvidence( + result.value.subject ?? undefined, + result.value.provider, + { + operation: OPERATION, + parameters: input, + result: result.value.result, + confidence: "observed", + locations: result.value.locations, + limitations: result.value.limitations, + }, + ); + } + if (result.ok && options?.signal?.aborted) { + result = err( + new AnalysisCancelledError( + OPERATION, + partialObservation === undefined + ? undefined + : { partialObservation }, + ), + ); + } if (cleanup.kind === "failed") { const cause = cleanup.cause; - const partialObservation = result.ok - ? createEvidence( - result.value.subject ?? undefined, - result.value.provider, - { - operation: OPERATION, - parameters: input, - result: result.value.result, - confidence: "observed", - locations: result.value.locations, - limitations: result.value.limitations, - }, - ) + const retainedObservation = result.ok + ? partialObservation : result.error.partialObservation; result = err( new ProviderCleanupError( @@ -477,9 +496,9 @@ export class WabtArtifactProvider implements WasmArtifactPort { { operation: OPERATION, cause, - ...(partialObservation === undefined + ...(retainedObservation === undefined ? {} - : { partialObservation }), + : { partialObservation: retainedObservation }), }, ), );