2026-10-19 11:37:41 +08:00
|
|
|
import { createHash } from "node:crypto";
|
|
|
|
|
import { mkdtemp, open, rm, stat, writeFile } from "node:fs/promises";
|
|
|
|
|
import { tmpdir } from "node:os";
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
|
|
|
|
|
import { afterEach, expect, it, vi } from "vitest";
|
2026-11-01 00:15:28 +08:00
|
|
|
import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs";
|
|
|
|
|
import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js";
|
2026-10-19 11:37:41 +08:00
|
|
|
|
|
|
|
|
const injected = vi.hoisted(() => ({
|
|
|
|
|
path: "",
|
|
|
|
|
closeFailure: undefined as Error | undefined,
|
|
|
|
|
primaryFailure: undefined as Error | undefined,
|
|
|
|
|
abortOnClose: undefined as (() => void) | undefined,
|
|
|
|
|
opened: [] as string[],
|
|
|
|
|
closed: [] as string[],
|
|
|
|
|
handles: [] as {
|
|
|
|
|
isOpen: () => boolean;
|
|
|
|
|
close: () => Promise<void>;
|
|
|
|
|
restore: () => void;
|
|
|
|
|
}[],
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock("node:fs/promises", async (importOriginal) => {
|
|
|
|
|
const original = await importOriginal<typeof import("node:fs/promises")>();
|
|
|
|
|
return {
|
|
|
|
|
...original,
|
|
|
|
|
open: async (...args: Parameters<typeof original.open>) => {
|
|
|
|
|
const handle = await original.open(...args);
|
|
|
|
|
const path = String(args[0]);
|
|
|
|
|
injected.opened.push(path);
|
|
|
|
|
if (path !== injected.path) return handle;
|
|
|
|
|
const nativeClose = handle.close.bind(handle);
|
|
|
|
|
injected.handles.push({
|
|
|
|
|
isOpen: () => handle.fd >= 0,
|
|
|
|
|
close: nativeClose,
|
|
|
|
|
restore: () => {
|
|
|
|
|
handle.close = nativeClose;
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
handle.close = async () => {
|
|
|
|
|
injected.closed.push(path);
|
|
|
|
|
injected.abortOnClose?.();
|
|
|
|
|
if (injected.closeFailure !== undefined) throw injected.closeFailure;
|
|
|
|
|
await nativeClose();
|
|
|
|
|
};
|
|
|
|
|
return handle;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-01 00:15:28 +08:00
|
|
|
import {
|
|
|
|
|
ProcessCaptureResourceScope,
|
|
|
|
|
observeSelectedExecutable,
|
|
|
|
|
} from "../../../src/process/capture/ProcessCaptureLifecycle.js";
|
2026-10-19 11:37:41 +08:00
|
|
|
import { ProcessCaptureReadFailure } from "../../../src/process/capture/ProcessCaptureRead.js";
|
|
|
|
|
|
|
|
|
|
const roots: string[] = [];
|
|
|
|
|
const createExecutable = async (content: string): Promise<string> => {
|
|
|
|
|
const root = await mkdtemp(join(tmpdir(), "rea-selected-executable-owner-"));
|
|
|
|
|
roots.push(root);
|
|
|
|
|
const path = join(root, "selected-fixture");
|
|
|
|
|
await writeFile(path, content);
|
|
|
|
|
return path;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const caughtFailure = async (
|
|
|
|
|
operation: () => Promise<unknown>,
|
|
|
|
|
): Promise<ProcessCaptureReadFailure<unknown>> => {
|
|
|
|
|
let cause: unknown;
|
|
|
|
|
try {
|
|
|
|
|
await operation();
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
cause = error;
|
|
|
|
|
}
|
|
|
|
|
expect(cause).toBeInstanceOf(ProcessCaptureReadFailure);
|
|
|
|
|
return cause as ProcessCaptureReadFailure<unknown>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
for (const handle of injected.handles.splice(0)) {
|
|
|
|
|
handle.restore();
|
|
|
|
|
if (!handle.isOpen()) continue;
|
|
|
|
|
try {
|
|
|
|
|
await handle.close();
|
|
|
|
|
} catch (cause: unknown) {
|
|
|
|
|
if (
|
|
|
|
|
handle.isOpen() ||
|
|
|
|
|
!(cause instanceof Error && "code" in cause && cause.code === "EBADF")
|
|
|
|
|
)
|
|
|
|
|
throw cause;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
injected.path = "";
|
|
|
|
|
injected.closeFailure = undefined;
|
|
|
|
|
injected.primaryFailure = undefined;
|
|
|
|
|
injected.abortOnClose = undefined;
|
|
|
|
|
injected.opened = [];
|
|
|
|
|
injected.closed = [];
|
|
|
|
|
await Promise.all(
|
|
|
|
|
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
|
|
|
|
);
|
2026-10-19 19:32:22 +08:00
|
|
|
vi.restoreAllMocks();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("cancels during executable bytes without transferring descriptor cleanup or blocking the scope", async () => {
|
|
|
|
|
const bytes = "executable bytes\n".repeat(16_384);
|
|
|
|
|
const path = await createExecutable(bytes);
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
const reason = new Error("cancel after the first physical executable read");
|
|
|
|
|
injected.path = path;
|
|
|
|
|
const scope = new ProcessCaptureResourceScope();
|
|
|
|
|
const fileSystem = {
|
|
|
|
|
open: async (selected: string, flags: number) => {
|
|
|
|
|
const handle = await open(selected, flags);
|
|
|
|
|
const read = handle.read.bind(handle);
|
|
|
|
|
const readAndCancel = async (
|
|
|
|
|
buffer: Buffer,
|
|
|
|
|
offset: number,
|
|
|
|
|
length: number,
|
|
|
|
|
position: number | bigint | null,
|
|
|
|
|
) => {
|
|
|
|
|
const result = await read(buffer, offset, length, position);
|
|
|
|
|
controller.abort(reason);
|
|
|
|
|
return result;
|
|
|
|
|
};
|
|
|
|
|
vi.spyOn(handle, "read").mockImplementation(
|
|
|
|
|
readAndCancel as typeof handle.read,
|
|
|
|
|
);
|
|
|
|
|
// Exercise the same physical-read interleaving in the old native-stream
|
|
|
|
|
// implementation: abort after bytes arrive, while its iterator owns data.
|
|
|
|
|
const stream = handle.createReadStream.bind(handle);
|
|
|
|
|
vi.spyOn(handle, "createReadStream").mockImplementation((options) => {
|
|
|
|
|
const source = stream(options);
|
|
|
|
|
source.once("data", () => controller.abort(reason));
|
|
|
|
|
return source;
|
|
|
|
|
});
|
|
|
|
|
return handle;
|
|
|
|
|
},
|
|
|
|
|
stat: (selected: string) => stat(selected, { bigint: true }),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const failure = await caughtFailure(() =>
|
|
|
|
|
scope.run(() =>
|
|
|
|
|
observeSelectedExecutable(path, controller.signal, fileSystem, scope),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(controller.signal.aborted).toBe(true);
|
|
|
|
|
expect(failure.cause).toBe(reason);
|
|
|
|
|
expect(failure.userCategory).toBe("cancelled");
|
|
|
|
|
expect(failure.cleanupIncomplete).toBe(false);
|
|
|
|
|
expect(failure.cleanupLeases).toEqual([]);
|
|
|
|
|
expect(injected.closed).toEqual([path]);
|
|
|
|
|
expect(injected.handles[0]?.isOpen()).toBe(false);
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
scope.run(() =>
|
|
|
|
|
observeSelectedExecutable(path, undefined, undefined, scope),
|
|
|
|
|
),
|
|
|
|
|
).resolves.toMatchObject({
|
|
|
|
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
|
|
|
});
|
|
|
|
|
await scope.close();
|
2026-10-19 11:37:41 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("retains the completed executable observation and descriptor owner after close failure", async () => {
|
|
|
|
|
const bytes = "small selected executable fixture";
|
|
|
|
|
const path = await createExecutable(bytes);
|
|
|
|
|
injected.path = path;
|
|
|
|
|
injected.closeFailure = new Error(
|
|
|
|
|
"injected selected executable close denial",
|
|
|
|
|
);
|
|
|
|
|
const scope = new ProcessCaptureResourceScope();
|
|
|
|
|
|
|
|
|
|
const failure = await caughtFailure(() =>
|
|
|
|
|
scope.run(() =>
|
|
|
|
|
observeSelectedExecutable(path, undefined, undefined, scope),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(failure.outcome.kind).toBe("completed");
|
|
|
|
|
if (failure.outcome.kind === "completed")
|
|
|
|
|
expect(failure.outcome.value).toMatchObject({
|
|
|
|
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
|
|
|
identity: expect.any(Object),
|
|
|
|
|
reason: null,
|
|
|
|
|
});
|
|
|
|
|
expect(failure.cleanupCause).toBe(injected.closeFailure);
|
|
|
|
|
expect(failure.cleanupLeases).toHaveLength(1);
|
|
|
|
|
expect(injected.opened).toEqual([path]);
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
scope.run(() =>
|
|
|
|
|
observeSelectedExecutable(path, undefined, undefined, scope),
|
|
|
|
|
),
|
|
|
|
|
).rejects.toMatchObject({ cleanupIncomplete: true });
|
|
|
|
|
expect(injected.opened).toEqual([path]);
|
|
|
|
|
|
|
|
|
|
injected.closeFailure = undefined;
|
|
|
|
|
await scope.retryCleanup();
|
|
|
|
|
expect(failure.cleanupIncomplete).toBe(false);
|
|
|
|
|
await expect(
|
|
|
|
|
scope.run(() =>
|
|
|
|
|
observeSelectedExecutable(path, undefined, undefined, scope),
|
|
|
|
|
),
|
|
|
|
|
).resolves.toMatchObject({
|
|
|
|
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
|
|
|
});
|
|
|
|
|
expect(injected.opened).toEqual([path, path]);
|
|
|
|
|
expect(injected.handles).toHaveLength(2);
|
|
|
|
|
expect(injected.handles.every((handle) => !handle.isOpen())).toBe(true);
|
|
|
|
|
await scope.close();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("preserves selected executable IO failure when descriptor close also fails", async () => {
|
|
|
|
|
const path = await createExecutable(
|
|
|
|
|
"selected executable with primary IO failure",
|
|
|
|
|
);
|
|
|
|
|
const primary = new Error("injected selected executable metadata failure");
|
|
|
|
|
injected.path = path;
|
|
|
|
|
injected.closeFailure = new Error(
|
|
|
|
|
"injected selected executable close denial",
|
|
|
|
|
);
|
|
|
|
|
const scope = new ProcessCaptureResourceScope();
|
|
|
|
|
const fileSystem = {
|
|
|
|
|
open,
|
|
|
|
|
stat: async () => {
|
|
|
|
|
throw primary;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const failure = await caughtFailure(() =>
|
|
|
|
|
observeSelectedExecutable(path, undefined, fileSystem, scope),
|
|
|
|
|
);
|
|
|
|
|
expect(failure.outcome).toMatchObject({ kind: "failed", cause: primary });
|
|
|
|
|
expect(failure.cause).toBe(primary);
|
|
|
|
|
expect(failure.cleanupCause).toBe(injected.closeFailure);
|
|
|
|
|
expect(failure.cleanupLeases).toHaveLength(1);
|
|
|
|
|
|
|
|
|
|
injected.closeFailure = undefined;
|
|
|
|
|
await scope.retryCleanup();
|
|
|
|
|
expect(failure.cleanupIncomplete).toBe(false);
|
|
|
|
|
await scope.close();
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-19 19:32:22 +08:00
|
|
|
it.each([
|
|
|
|
|
[false, false],
|
|
|
|
|
[false, true],
|
|
|
|
|
[true, false],
|
|
|
|
|
[true, true],
|
|
|
|
|
])(
|
|
|
|
|
"retains pending read failure and cancellation (system error: %s, close denied: %s)",
|
|
|
|
|
async (systemError, closeDenied) => {
|
|
|
|
|
const path = await createExecutable("selected executable read failure");
|
|
|
|
|
const primary = new Error("physical executable read rejected");
|
|
|
|
|
if (systemError) Object.assign(primary, { code: "EIO", syscall: "read" });
|
|
|
|
|
const reason = { request: "cancel while the descriptor read is pending" };
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
let enter!: () => void;
|
|
|
|
|
let release!: () => void;
|
|
|
|
|
const entered = new Promise<void>((resolve) => {
|
|
|
|
|
enter = resolve;
|
|
|
|
|
});
|
|
|
|
|
const pending = new Promise<void>((resolve) => {
|
|
|
|
|
release = resolve;
|
|
|
|
|
});
|
|
|
|
|
injected.path = path;
|
|
|
|
|
injected.closeFailure = closeDenied
|
|
|
|
|
? new Error("descriptor close denied after failed read")
|
|
|
|
|
: undefined;
|
|
|
|
|
const scope = new ProcessCaptureResourceScope();
|
|
|
|
|
const fileSystem = {
|
|
|
|
|
open: async (selected: string, flags: number) => {
|
|
|
|
|
const handle = await open(selected, flags);
|
|
|
|
|
handle.read = async () => {
|
|
|
|
|
enter();
|
|
|
|
|
await pending;
|
|
|
|
|
throw primary;
|
|
|
|
|
};
|
|
|
|
|
return handle;
|
|
|
|
|
},
|
|
|
|
|
stat: (selected: string) => stat(selected, { bigint: true }),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const reading = caughtFailure(() =>
|
|
|
|
|
scope.run(() =>
|
|
|
|
|
observeSelectedExecutable(path, controller.signal, fileSystem, scope),
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-11-01 00:15:28 +08:00
|
|
|
await withVerifierCleanup(async () => {
|
|
|
|
|
await awaitAcknowledgement(entered, reading, "selected executable read");
|
|
|
|
|
controller.abort(reason);
|
|
|
|
|
release();
|
|
|
|
|
const failure = await reading;
|
|
|
|
|
expect(failure.cause).toBe(primary);
|
|
|
|
|
expect(failure.outcome).toMatchObject({ kind: "failed", cause: primary });
|
|
|
|
|
expect(failure.cancellation?.reason).toBe(reason);
|
|
|
|
|
expect(failure.userCategory).toBeUndefined();
|
|
|
|
|
expect(failure.cleanupIncomplete).toBe(closeDenied);
|
|
|
|
|
expect(failure.cleanupCause).toBe(injected.closeFailure);
|
|
|
|
|
expect(failure.cleanupLeases).toHaveLength(closeDenied ? 1 : 0);
|
|
|
|
|
expect(injected.closed).toEqual([path]);
|
2026-10-19 19:32:22 +08:00
|
|
|
|
2026-11-01 00:15:28 +08:00
|
|
|
injected.closeFailure = undefined;
|
|
|
|
|
await scope.retryCleanup();
|
|
|
|
|
expect(failure.cleanupIncomplete).toBe(false);
|
|
|
|
|
expect(injected.handles[0]?.isOpen()).toBe(false);
|
|
|
|
|
await expect(
|
|
|
|
|
scope.run(() =>
|
|
|
|
|
observeSelectedExecutable(path, undefined, undefined, scope),
|
|
|
|
|
),
|
|
|
|
|
).resolves.toMatchObject({
|
|
|
|
|
sha256: createHash("sha256")
|
|
|
|
|
.update("selected executable read failure")
|
|
|
|
|
.digest("hex"),
|
|
|
|
|
});
|
|
|
|
|
await scope.close();
|
|
|
|
|
}, [
|
|
|
|
|
() => controller.abort(),
|
|
|
|
|
release,
|
|
|
|
|
async () => {
|
|
|
|
|
await reading;
|
|
|
|
|
},
|
|
|
|
|
() => {
|
|
|
|
|
injected.closeFailure = undefined;
|
|
|
|
|
},
|
|
|
|
|
() => scope.close(),
|
|
|
|
|
]);
|
2026-10-19 19:32:22 +08:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-10-19 11:37:41 +08:00
|
|
|
it("keeps the exact cancellation reason when cancellation arrives during descriptor close", async () => {
|
|
|
|
|
const path = await createExecutable(
|
|
|
|
|
"selected executable cancelled during close",
|
|
|
|
|
);
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
const reason = new Error("cancel during selected executable close");
|
|
|
|
|
const closeFailure = new Error("injected close denial after cancellation");
|
|
|
|
|
injected.path = path;
|
|
|
|
|
injected.closeFailure = closeFailure;
|
|
|
|
|
injected.abortOnClose = () => controller.abort(reason);
|
|
|
|
|
const scope = new ProcessCaptureResourceScope();
|
|
|
|
|
|
|
|
|
|
const failure = await caughtFailure(() =>
|
|
|
|
|
observeSelectedExecutable(path, controller.signal, undefined, scope),
|
|
|
|
|
);
|
|
|
|
|
expect(failure.outcome.kind).toBe("completed");
|
|
|
|
|
expect(failure.cause).toBe(reason);
|
|
|
|
|
expect(failure.cancellation).toEqual({ reason });
|
|
|
|
|
expect(failure.cleanupCause).toBe(closeFailure);
|
|
|
|
|
expect(failure.userCategory).toBe("cancelled");
|
|
|
|
|
expect(failure.cleanupLeases).toHaveLength(1);
|
|
|
|
|
|
|
|
|
|
injected.abortOnClose = undefined;
|
|
|
|
|
injected.closeFailure = undefined;
|
|
|
|
|
await scope.retryCleanup();
|
|
|
|
|
expect(failure.cleanupIncomplete).toBe(false);
|
|
|
|
|
await scope.close();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns a completed executable observation when descriptor close succeeds without cancellation", async () => {
|
|
|
|
|
const bytes = "successful selected executable fixture";
|
|
|
|
|
const path = await createExecutable(bytes);
|
|
|
|
|
injected.path = path;
|
|
|
|
|
const scope = new ProcessCaptureResourceScope();
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
scope.run(() =>
|
|
|
|
|
observeSelectedExecutable(path, undefined, undefined, scope),
|
|
|
|
|
),
|
|
|
|
|
).resolves.toMatchObject({
|
|
|
|
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
|
|
|
identity: expect.any(Object),
|
|
|
|
|
reason: null,
|
|
|
|
|
});
|
|
|
|
|
expect(injected.opened).toEqual([path]);
|
|
|
|
|
expect(injected.handles).toHaveLength(1);
|
|
|
|
|
expect(injected.handles[0]?.isOpen()).toBe(false);
|
|
|
|
|
await scope.close();
|
|
|
|
|
});
|