fix(lifecycle): preserve source failures and native workspace diagnostics

This commit is contained in:
morluto committed 2026-10-26 22:22:27 +08:00
1 parent 77468d5043
commit 4a8205456c
5 files changed
+242 -48

No files matched your search

+59 -25
View File
@@ -1,8 +1,14 @@
import { z } from "zod";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import {
artifactFileCancellationCause,
artifactFilePrimaryFacts,
bindArtifactFileCleanup,
type ArtifactFilePrimaryFacts,
} from "../../artifacts/ArtifactFileOperation.js";
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisAccessDeniedError,
AnalysisCancelledError,
@@ -65,44 +71,69 @@ export const webArtifactReadError = (
context: WebArtifactReadContext,
signal?: AbortSignal,
): AnalysisError => {
const primary = webArtifactPrimaryError(
artifactFilePrimaryFacts(cause),
context,
signal,
);
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) {
const primary =
signal?.aborted === true
? new AnalysisCancelledError(context.operation, {
cause: signal.reason,
})
: new ArtifactOperationError(
context.operation,
cause.reason,
cause.details,
cause.message,
{ cause },
);
const cleanup = new ProviderCleanupError(
"captured-web-artifacts",
cause.cleanup.resources,
{ reason: cause.cleanup.reason },
{ cause, operation: context.operation },
);
bindArtifactFileCleanup(cause, cleanup);
return analysisErrorWithCleanupFailure(
primary,
new ProviderCleanupError(
"captured-web-artifacts",
cause.cleanup.resources,
{ reason: cause.cleanup.reason },
{ cause, operation: context.operation },
),
cleanup,
context.operation,
);
).bindCleanupDependencies([primary, cleanup]);
}
if (signal?.aborted === true)
return primary;
};
const webArtifactPrimaryError = (
{ cause, origin }: ArtifactFilePrimaryFacts,
context: WebArtifactReadContext,
signal?: AbortSignal,
): AnalysisError => {
if (origin === "owned-or-classified" && cause instanceof AnalysisError)
return cause;
if (
origin === "owned-or-classified" &&
cause instanceof ArtifactReaderFailure &&
cause.reason === "cancelled"
)
return new AnalysisCancelledError(context.operation, {
cause: artifactFileCancellationCause(cause),
});
if (
origin === "owned-or-classified" &&
signal?.aborted === true &&
(cause === signal.reason ||
(cause instanceof Error &&
cause.name === "AbortError" &&
"code" in cause &&
cause.code === "ABORT_ERR"))
)
return new AnalysisCancelledError(context.operation, {
cause: signal.reason,
});
const selection = context.callerSelected
? selectedFileError(cause, context)
? selectedFileError({ cause, origin }, context)
: undefined;
if (selection !== undefined) return selection;
if (cause instanceof ArtifactReaderFailure)
if (
origin === "owned-or-classified" &&
cause instanceof ArtifactReaderFailure
)
return new ArtifactOperationError(
context.operation,
cause.reason,
cause.details,
cause.message,
{ cause },
);
if (
cause instanceof Error &&
@@ -149,14 +180,17 @@ export const webArtifactReadError = (
* caller's selection or a host permission denial, not an artifact fault.
*/
const selectedFileError = (
cause: unknown,
{ cause, origin }: ArtifactFilePrimaryFacts,
context: WebArtifactReadContext,
): AnalysisError | undefined => {
const selected = (message: string) =>
new AnalysisInputError(context.operation, { cause }, [
{ path: context.field, reason: "invalid_value", message },
]);
if (cause instanceof ArtifactReaderFailure)
if (
origin === "owned-or-classified" &&
cause instanceof ArtifactReaderFailure
)
return cause.reason === "path" ? selected(cause.message) : undefined;
const code =
cause instanceof Error && "code" in cause ? String(cause.code) : "";
+5 -6
View File
@@ -68,12 +68,11 @@ export class PrivateRuntimeRoot {
return new PrivateRuntimeRoot(runtime.observation.path, runtime);
}
const selectedParent = options.parent ?? tmpdir();
// Validate native traversal before canonicalizing the existing parent. The
// allocated private path then has one physical spelling for every consumer.
if (!(await stat(selectedParent)).isDirectory())
throw new Error(
`Private runtime parent is not a directory: ${selectedParent}`,
);
// The final dot requires a native directory lookup, preserving the host's
// failure code and path. An explicitly empty selection remains invalid.
await stat(selectedParent === "" ? selectedParent : `${selectedParent}/.`);
// Canonicalize only after native admission so consumers share one physical
// spelling without collapsing an invalid file/.. traversal on Darwin.
const parent = await realpath(selectedParent);
const path = await mkdtemp(join(parent, options.prefix ?? "rea-provider-"));
// POSIX mkdtemp establishes owner-only access atomically. Return its owner
@@ -5,8 +5,12 @@ import { expect, it, onTestFinished, vi } from "vitest";
const injected = vi.hoisted(() => ({
sourcePath: undefined as string | undefined,
abortController: undefined as AbortController | undefined,
abortDuringClose: false,
abortReason: undefined as unknown,
readFailure: undefined as Error | undefined,
failClose: false,
closeAttempts: 0,
sourceHandle: undefined as import("node:fs/promises").FileHandle | undefined,
}));
vi.mock("node:fs/promises", async (importOriginal) => {
@@ -16,14 +20,27 @@ vi.mock("node:fs/promises", async (importOriginal) => {
open: async (...args: Parameters<typeof actual.open>) => {
const handle = await actual.open(...args);
if (args[0] !== injected.sourcePath) return handle;
const createReadStream = handle.createReadStream.bind(handle);
handle.createReadStream = (options) => {
injected.abortController?.abort();
return createReadStream(options);
};
injected.sourceHandle = handle;
const read = handle.read.bind(handle);
Object.defineProperty(handle, "read", {
value: async (
buffer: Buffer,
offset: number,
length: number,
position: number,
) => {
if (injected.readFailure !== undefined) throw injected.readFailure;
const result = await read(buffer, offset, length, position);
if (!injected.abortDuringClose)
injected.abortController?.abort(injected.abortReason);
return result;
},
});
const close = handle.close.bind(handle);
handle.close = async () => {
injected.closeAttempts += 1;
if (injected.abortDuringClose)
injected.abortController?.abort(injected.abortReason);
if (injected.failClose) throw new Error("fixture close refusal");
await close();
};
@@ -38,29 +55,43 @@ import { LocalWebModuleArtifacts } from "../../../src/browser/modules/WebModuleA
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { primaryAnalysisError } from "../../../src/domain/analysisErrorBase.js";
import { webModuleArtifactsFixture } from "../../fixtures/webModuleTrace.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it("retains cancelled source read ownership until service close retries it", async () => {
const sourceFixture = async (source: string) => {
const root = await createTestTempDirectory("rea-web-artifact-read-owner-");
onTestFinished(() => {
injected.sourcePath = undefined;
injected.abortController = undefined;
injected.failClose = false;
injected.closeAttempts = 0;
});
const source = 'import "./dep.js";';
const fixture = webModuleArtifactsFixture(source);
const manifestPath = join(root, "manifest.json");
const sourcePath = join(root, "files", "modules", "main.js");
await mkdir(join(root, "files", "modules"), { recursive: true });
await writeFile(sourcePath, source);
await writeFile(manifestPath, JSON.stringify(fixture.manifest));
return {
input: { manifest_path: manifestPath, script_index: 0 },
sourcePath,
fixture,
};
};
it("retains cancelled source read ownership until service close retries it", async () => {
onTestFinished(() => {
injected.sourcePath = undefined;
injected.abortController = undefined;
injected.abortDuringClose = false;
injected.abortReason = undefined;
injected.readFailure = undefined;
injected.failClose = false;
injected.closeAttempts = 0;
injected.sourceHandle = undefined;
});
const { input, sourcePath } = await sourceFixture('import "./dep.js";');
injected.sourcePath = sourcePath;
injected.failClose = true;
const controller = new AbortController();
injected.abortController = controller;
injected.abortReason = { request: "cancel after source read" };
const resolver = {
resolve: vi.fn(),
close: vi.fn(async () => undefined),
@@ -69,7 +100,11 @@ it("retains cancelled source read ownership until service close retries it", asy
new LocalWebModuleArtifacts(),
resolver,
);
const input = { manifest_path: manifestPath, script_index: 0 };
onTestFinished(async () => {
injected.failClose = false;
injected.abortController = undefined;
await service.close();
});
const cancelled = await service.trace(input, { signal: controller.signal });
expect(cancelled.ok).toBe(false);
@@ -77,6 +112,9 @@ it("retains cancelled source read ownership until service close retries it", asy
expect(cancelled.error.cleanupIncomplete).toBe(true);
expect(cancelled.error.cleanupResources).toContain(sourcePath);
expect(resolver.resolve).not.toHaveBeenCalled();
expect(primaryAnalysisError(cancelled.error).cause).toBe(
injected.abortReason,
);
injected.abortController = undefined;
const blocked = await service.trace(input);
@@ -119,8 +157,105 @@ it("retains cancelled source read ownership until service close retries it", asy
await service.close();
expect(injected.closeAttempts).toBeGreaterThan(attemptsBeforeRetry);
expect(resolver.close).toHaveBeenCalledTimes(3);
expect(injected.sourceHandle?.fd).toBe(-1);
for (const error of [cancelled.error, blocked.error, cleanupFailure]) {
expect(error).toMatchObject({
cleanupIncomplete: false,
cleanupResources: [],
});
}
});
it.each([
{ readFails: true, closeFails: false, cancel: true },
{ readFails: true, closeFails: true, cancel: true },
{ readFails: true, closeFails: true, cancel: false },
{ readFails: false, closeFails: false, cancel: true },
{ readFails: false, closeFails: true, cancel: true },
{ readFails: false, closeFails: false, cancel: false },
])(
"keeps physical source read failure and exact cleanup ownership %j",
async ({ readFails, closeFails, cancel }) => {
const { input, sourcePath, fixture } = await sourceFixture(
"export const value = 1;",
);
const physical = Object.assign(new Error("fixture descriptor EIO"), {
code: "EIO",
syscall: "read",
});
const reason = { request: "cancel during failed source finalization" };
const controller = new AbortController();
injected.sourcePath = sourcePath;
injected.readFailure = readFails ? physical : undefined;
injected.failClose = closeFails;
injected.abortController = cancel ? controller : undefined;
injected.abortDuringClose = true;
injected.abortReason = reason;
injected.closeAttempts = 0;
const service = new WebModuleTraceService(new LocalWebModuleArtifacts(), {
resolve: vi.fn(),
});
onTestFinished(async () => {
injected.failClose = false;
injected.abortController = undefined;
await service.close();
injected.sourcePath = undefined;
injected.readFailure = undefined;
injected.abortDuringClose = false;
injected.abortReason = undefined;
injected.closeAttempts = 0;
injected.sourceHandle = undefined;
});
const result = await service.trace(input, { signal: controller.signal });
expect(controller.signal.aborted).toBe(cancel);
if (cancel) expect(controller.signal.reason).toBe(reason);
if (!readFails && !cancel) {
if (!result.ok) throw result.error;
expect(result.value.normalized_result).toMatchObject({
source: { path: sourcePath, sha256: fixture.sourceFile.sha256 },
manifest: { path: input.manifest_path },
});
await service.close();
expect(injected.sourceHandle?.fd).toBe(-1);
expect(injected.closeAttempts).toBe(1);
return;
}
if (result.ok) throw new Error("Expected source read failure");
const primary = primaryAnalysisError(result.error);
if (readFails)
expect(primary).toMatchObject({
_tag: "ArtifactOperationError",
reason: "io",
});
else expect(primary).toBeInstanceOf(AnalysisCancelledError);
expect(primary.cause).toBe(readFails ? physical : reason);
expect(result.error.partialObservation).toBeUndefined();
expect(result.error.cleanupIncomplete).toBe(closeFails);
if (closeFails) {
expect(result.error.cleanupResources).toEqual([sourcePath]);
expect(injected.sourceHandle?.fd).toBeGreaterThanOrEqual(0);
}
injected.failClose = false;
await service.close();
expect(injected.sourceHandle?.fd).toBe(-1);
expect(injected.closeAttempts).toBe(closeFails ? 2 : 1);
expect(result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupResources).toEqual([]);
expect(primaryAnalysisError(result.error)).toBe(primary);
expect(projectAnalysisError(result.error)).toMatchObject({
code: readFails ? "artifact_operation_failed" : "cancelled",
...(readFails
? {
details: {
reason: "io",
detail: expect.stringContaining("EIO"),
},
}
: {}),
});
},
);
it("preserves both web cleanup owners' resources, diagnostics and recovery actions", async () => {
const artifacts = new ProviderCleanupError(
"web-artifacts",
@@ -118,7 +118,10 @@ it.each<readonly [string, string]>([
diagnostics: {
stage: "workspace",
workspace_path: null,
path: expect.stringContaining(join(parent, "rea-pcap-")) as unknown,
path:
code === "ENOTDIR"
? `${parent}/.`
: (expect.stringContaining(join(parent, "rea-pcap-")) as unknown),
system_code: code,
},
},
@@ -12,6 +12,7 @@ import {
} from "vitest";
import { z } from "zod";
import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js";
import { ProviderCleanupError } from "../../src/domain/providerCleanupError.js";
import { PwntoolsRecordedCrashProvider } from "../../src/native/pwntools/PwntoolsRecordedCrashProvider.js";
import type { PwntoolsLauncher } from "../../src/native/pwntools/PwntoolsDecoder.js";
import {
@@ -390,6 +391,7 @@ it.runIf(supportedHost).each(["decoder", "debugger"])(
const read = artifacts.readStableArtifact;
let injected = false;
let releaseAllowed = false;
const closeFailure = new Error("reply close refused");
const emergencyCloses: (() => Promise<void>)[] = [];
vi.spyOn(artifacts, "readStableArtifact").mockImplementation(
async (...args) => {
@@ -402,7 +404,7 @@ it.runIf(supportedHost).each(["decoder", "debugger"])(
const close = file.close.bind(file);
emergencyCloses.push(close);
file.close = async () => {
if (!releaseAllowed) throw new Error("reply close refused");
if (!releaseAllowed) throw closeFailure;
await close();
};
const retain = resources.retain.bind(resources);
@@ -454,17 +456,36 @@ it.runIf(supportedHost).each(["decoder", "debugger"])(
{ signal: controller.signal },
);
if (result.ok) throw new Error("Expected the physical reply-close failure");
expect(result.error._tag).toBe("AnalysisOutputError");
expect(controller.signal.reason).toBe(reason);
if (stage === "debugger") {
expect(result.error).toBeInstanceOf(ProviderCleanupError);
expect(result.error.diagnostics).toMatchObject({
stage: "debugger_reply_descriptor_close",
});
expect(result.error.partialObservation).toMatchObject({
artifact: { path },
debugger: {
status: "available",
connection: "core",
maps: recordedCrashDebuggerFixture().maps,
diagnostics: { stdout: "debugger diagnostic" },
},
});
} else expect(result.error._tag).toBe("AnalysisOutputError");
expect(result.error.cause).toBeInstanceOf(ArtifactFileCleanupFailure);
if (!(result.error.cause instanceof ArtifactFileCleanupFailure))
throw new Error("Expected exact file owner");
expect(result.error.cause.fileCleanupFailure.outcome.kind).toBe(
"completed",
);
expect(result.error.cause.fileCleanupFailure.cleanupCause).toBe(
closeFailure,
);
expect(
result.error.cause.fileCleanupFailure.completionCancellation,
).toBeUndefined();
expect(result.error.cleanupIncomplete).toBe(true);
const observation = result.error.partialObservation;
await expect(provider.close()).rejects.toMatchObject({
cleanupIncomplete: true,
});
@@ -472,6 +493,8 @@ it.runIf(supportedHost).each(["decoder", "debugger"])(
releaseAllowed = true;
await provider.close();
expect(result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupResources).toEqual([]);
expect(result.error.partialObservation).toBe(observation);
expect(result.error.capturedOutput?.stdout).toContain("basic diagnostic");
await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" });
},