Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e1f48dd68 |
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user