mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(lifecycle): preserve source failures and native workspace diagnostics
This commit is contained in:
1 parent
77468d5043
commit
4a8205456c
5 files changed
+242
-48
No files matched your search
@@ -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) : "";
|
||||
|
||||
@@ -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" });
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user