Compare commits

...
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 3e1f48dd68 fix(wasm): resolve WABT runtime cleanup after a successful retry
WabtArtifactProvider retained a failed rea-wabt-* runtime root in its
ArtifactResourceScope but returned an unbound ProviderCleanupError. A
later successful close() released the root without updating that error,
so CLI/MCP output kept reporting cleanup_incomplete and the removed root
as a live resource. A primary failure such as WABT rejecting the module
was also flattened into the cleanup error's previous_error diagnostic.

Retain the runtime root through an AnalysisCleanupLease and bind the
returned cleanup error to it with AnalysisCleanupLeaseGroup. Compose a
primary failure with analysisErrorWithCleanupFailure and
bindCleanupDependencies so the typed primary (for example
invalid_request or cancelled) becomes the resolved error once the lease
confirms release. Retry errors from admission and close() now bind to the
same exact leases through bindArtifactFileCleanup.

Fixes #4787

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:59:38 +00:00
4 changed files with 199 additions and 28 deletions

No files matched your search

+23 -1
View File
@@ -105,6 +105,17 @@ const assertCleanupRecovery = async (options: {
await expect(access(options.runtimePath)).rejects.toMatchObject({
code: "ENOENT",
});
// The root's own successful retry resolves both returned errors.
for (const returned of [options.result, blocked])
expect(returned).toMatchObject({
ok: false,
error: { cleanupIncomplete: false, cleanupResources: [] },
});
expect(options.result).toMatchObject({
error: {
partialObservation: { normalized_result: { validation: "valid" } },
},
});
};
const assertCancellationDuringRootRelease = async (options: {
@@ -132,13 +143,24 @@ const assertCancellationDuringRootRelease = async (options: {
cleanupIncomplete: true,
cleanupResources: [options.runtimePath],
partialObservation: { normalized_result: { validation: "valid" } },
diagnostics: { previous_error: { code: "cancelled" } },
diagnostics: { primary_error: { code: "cancelled" } },
},
});
expect(options.cleanupAttempts()).toBe(1);
await expect(access(options.runtimePath)).resolves.toBeUndefined();
await options.provider.close();
expect(options.cleanupAttempts()).toBe(2);
if (options.result.ok) throw new Error("Cancellation must remain an error");
expect(options.result.error).toMatchObject({
cleanupIncomplete: false,
cleanupResources: [],
});
expect(projectAnalysisError(options.result.error)).toMatchObject({
code: "cancelled",
details: {
partial_observation: { normalized_result: { validation: "valid" } },
},
});
};
const createWabtArtifactFixture = async () => {
+41 -26
View File
@@ -11,6 +11,11 @@ import type {
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import {
AnalysisCleanupLease,
AnalysisCleanupLeaseGroup,
} from "../artifacts/AnalysisCleanupLease.js";
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
import type {
AnalysisError,
AnalysisCapturedOutput,
@@ -23,8 +28,8 @@ import {
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { createEvidence } from "../domain/evidence.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
wasmArtifactSchema,
type InspectWasmArtifactInput,
@@ -59,7 +64,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
} catch (cause: unknown) {
const cleanup =
cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined;
throw new ProviderCleanupError(
const failure = new ProviderCleanupError(
WABT_PROVIDER_IDENTITY.id,
cleanup?.resources ?? ["artifact-resources"],
{
@@ -69,6 +74,8 @@ export class WabtArtifactProvider implements WasmArtifactPort {
},
{ operation: OPERATION, cause },
);
bindArtifactFileCleanup(cause, failure);
throw failure;
}
}
async inspect(
@@ -84,7 +91,9 @@ export class WabtArtifactProvider implements WasmArtifactPort {
cause.cleanup === undefined
)
return err(this.#closedError());
return err(wabtFailure(cause, "artifact", input.path));
const failure = wabtFailure(cause, "artifact", input.path);
bindArtifactFileCleanup(cause, failure);
return err(failure);
}
}
@@ -443,10 +452,15 @@ export class WabtArtifactProvider implements WasmArtifactPort {
);
}
if (root !== undefined) {
const runtime = root;
const rootLease = new AnalysisCleanupLease(
() => runtime.close(),
[runtime.path],
);
const cleanup = await this.#resources.release({
kind: "private-runtime-root",
resource: root.path,
root,
kind: "lease",
resource: runtime.path,
lease: rootLease,
});
let partialObservation = result.ok
? undefined
@@ -480,27 +494,28 @@ export class WabtArtifactProvider implements WasmArtifactPort {
}
if (cleanup.kind === "failed") {
const cause = cleanup.cause;
const retainedObservation = result.ok
? partialObservation
: result.error.partialObservation;
const failure = new ProviderCleanupError(
WABT_PROVIDER_IDENTITY.id,
[root.path],
{ reason: cause instanceof Error ? cause.message : String(cause) },
{
operation: OPERATION,
cause,
...(result.ok && partialObservation !== undefined
? { partialObservation }
: {}),
},
);
// The retained lease resolves this error when its retry removes the root.
new AnalysisCleanupLeaseGroup(failure, [rootLease]);
result = err(
new ProviderCleanupError(
WABT_PROVIDER_IDENTITY.id,
[root.path],
{
reason: cause instanceof Error ? cause.message : String(cause),
previous_error: result.ok
? null
: projectAnalysisError(result.error),
},
{
operation: OPERATION,
cause,
...(retainedObservation === undefined
? {}
: { partialObservation: retainedObservation }),
},
),
result.ok
? failure
: analysisErrorWithCleanupFailure(
result.error,
failure,
OPERATION,
).bindCleanupDependencies([result.error, failure]),
);
}
}
+91 -1
View File
@@ -1,4 +1,4 @@
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -123,3 +123,93 @@ it.runIf(canDenyCleanup)(
}
},
);
it.runIf(process.platform !== "win32")(
"resolves runtime-root cleanup after the owner's close retry succeeds",
async () => {
const directory = await createTestTempDirectory("rea-wasm-cleanup-retry-");
const runtimeTemp = join(directory, "tmp");
const tools = join(directory, "tools");
const artifact = join(directory, "module.wasm");
await mkdir(runtimeTemp);
await mkdir(tools);
await writeFile(
artifact,
Buffer.from(
"0061736d01000000010401600000020b0103656e7603666f6f0000030201000503010001071002066d656d6f727902000372756e00010a0601040001010b000c04746573747061796c6f6164",
"hex",
),
);
const makeTool = async (name: string, body: string) => {
const path = join(tools, name);
await writeFile(
path,
`#!/bin/sh\nif [ "$1" = "--version" ]; then echo '1.0.42'; exit 0; fi\n${body}\n`,
{ mode: 0o700 },
);
await chmod(path, 0o700);
};
await makeTool(
"wasm-validate",
`if [ "$REA_REJECT_MODULE" = "1" ]; then echo 'fixture validation rejected' >&2; exit 1; fi\nexit 0`,
);
await makeTool("wasm-objdump", `cat "$REA_FIXTURE_OBJDUMP"`);
await makeTool(
"wasm2wat",
`printf '%s\\n' '(module (@custom "test" "payload"))'`,
);
const invoke = async (name: string, extra: NodeJS.ProcessEnv = {}) => {
const trace = join(directory, `${name}.trace`);
const output = await runCliJson(
["inspect-wasm-artifact", artifact, "--format", "json"],
{
...process.env,
...extra,
NODE_OPTIONS: `--import=${new URL("../../fixtures/wasm/denyFirstRuntimeRootRemoval.mjs", import.meta.url).href}`,
REA_RUNTIME_ROOT_REMOVAL_TRACE: trace,
REA_WABT_BIN_DIRECTORY: tools,
REA_FIXTURE_OBJDUMP: fileURLToPath(
new URL("../../fixtures/wasm/objdump.txt", import.meta.url),
),
TMPDIR: runtimeTemp,
TMP: runtimeTemp,
TEMP: runtimeTemp,
},
);
// The owner's first removal is denied; its close retry really removes it.
const attempts = (await readFile(trace, "utf8")).trim().split("\n");
const root = attempts[0]?.slice("denied ".length) ?? "";
expect(attempts).toEqual([`denied ${root}`, `removed ${root}`]);
await expect(readdir(runtimeTemp)).resolves.toEqual([]);
const projection = analysisErrorProjectionSchema.parse(output);
expect(projection.code).not.toBe("cleanup_incomplete");
expect(projection.details).not.toHaveProperty("cleanup", "incomplete");
// Historical cleanup diagnostics may name the root; live resources may not.
const details = projection.details ?? {};
const resources = "resources" in details ? details.resources : [];
expect(Array.isArray(resources) ? resources : []).not.toContain(root);
return projection;
};
const completed = await invoke("completed");
expect(completed).toMatchObject({
code: "execution_failure",
details: {
partial_observation: { normalized_result: { validation: "valid" } },
},
});
const rejected = await invoke("rejected", { REA_REJECT_MODULE: "1" });
expect(rejected).toMatchObject({
code: "invalid_request",
details: {
partial_observation: {
normalized_result: { validation: "rejected-by-selected-profile" },
},
},
});
expect(JSON.stringify(rejected.details)).toContain(
"fixture validation rejected",
);
},
);
+44
View File
@@ -0,0 +1,44 @@
/** Deny only the first removal of a CLI-owned `rea-wabt-*` runtime root.
* Later removals use the real filesystem. Each attempt is appended to
* `REA_RUNTIME_ROOT_REMOVAL_TRACE` as `denied|removed|failed <path>`.
*/
import { appendFileSync, existsSync } from "node:fs";
import fsPromises from "node:fs/promises";
import { syncBuiltinESMExports } from "node:module";
import { tmpdir } from "node:os";
import { basename, dirname } from "node:path";
const trace = process.env.REA_RUNTIME_ROOT_REMOVAL_TRACE ?? "";
const original = fsPromises.rm;
let denied = false;
fsPromises.rm = async (path, options) => {
const target = String(path);
if (
trace === "" ||
dirname(target) !== tmpdir() ||
!basename(target).startsWith("rea-wabt-")
)
return original(path, options);
if (!denied) {
denied = true;
appendFileSync(trace, `denied ${target}\n`);
throw Object.assign(
new Error(`EACCES: permission denied, rm '${target}'`),
{
code: "EACCES",
},
);
}
try {
await original(path, options);
} catch (cause) {
appendFileSync(trace, `failed ${target}\n`);
throw cause;
}
appendFileSync(
trace,
`${existsSync(target) ? "failed" : "removed"} ${target}\n`,
);
};
syncBuiltinESMExports();