fix(artifacts): retain reader owners after cleanup failures

Keep directory handles under explicit close ownership and retain ZIP and Mach-O admission owners when initialization or cleanup fails. Preserve cancellation and primary failure details, and avoid false successful release after native handle invalidation.

Validation: filesystem ownership, FIFO, archive and reconstruction boundary checks; static and architecture checks; real macOS CLI/MCP bundle workflows. Windows native behavior was not verified.
This commit is contained in:
morluto committed 2026-10-11 05:33:05 +08:00
1 parent be75ed5e4b
commit 5502ca9c97
7 files changed
+751 -77

No files matched your search

+269 -69
View File
@@ -1,23 +1,32 @@
import { constants } from "node:fs";
import { lstat, open, opendir, realpath } from "node:fs/promises";
import { constants, type Dir, type Stats } from "node:fs";
import {
lstat,
open,
opendir,
realpath,
type FileHandle,
} from "node:fs/promises";
import { isAbsolute, join, relative, sep } from "node:path";
import type { Readable } from "node:stream";
import { Readable } from "node:stream";
import {
ArtifactReaderFailure,
type ArtifactEntry,
type ArtifactReader,
} from "./ArtifactReader.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
/** Non-symlink-following directory reader. */
export class DirectoryArtifactReader implements ArtifactReader {
readonly format = "directory" as const;
readonly #rootPromise: Promise<string>;
#rootPromise: Promise<string> | undefined;
readonly #rootPath: string;
readonly #directories = new Set<OwnedDirectoryHandle>();
readonly #files = new Map<OwnedFileHandle, string>();
constructor(root: string) {
this.#rootPath = root;
this.#rootPromise = realpath(root);
}
/** Enumerate entries, optionally restricting descent while retaining directories. */
@@ -25,9 +34,7 @@ export class DirectoryArtifactReader implements ArtifactReader {
signal?: AbortSignal,
descendInto?: (path: string) => boolean,
): AsyncIterable<ArtifactEntry> {
const root = await this.#rootPromise.catch((cause: unknown) => {
throw directoryIoFailure("resolve artifact root", this.#rootPath, cause);
});
const root = await this.#resolveRoot();
const pending = [root];
while (pending.length > 0) {
abortIfNeeded(signal);
@@ -36,62 +43,100 @@ export class DirectoryArtifactReader implements ArtifactReader {
await withDirectoryIoContext("inspect directory", directory, () =>
assertContainedDirectory(root, directory),
);
const handle = await opendir(directory).catch((cause: unknown) => {
throw directoryIoFailure("open directory", directory, cause);
});
const directories: string[] = [];
try {
for await (const child of handle) {
abortIfNeeded(signal);
const absolute = join(directory, child.name);
const metadata = await lstat(absolute).catch((cause: unknown) => {
throw directoryIoFailure("inspect entry", absolute, cause);
});
const path = relative(root, absolute).split(sep).join("/");
const kind = metadata.isSymbolicLink()
? "symlink"
: metadata.isDirectory()
? "directory"
: metadata.isFile()
? "file"
: undefined;
if (kind === undefined) continue;
yield {
path,
kind,
declaredSize: kind === "file" ? metadata.size : null,
compressedSize: null,
executable: (metadata.mode & 0o111) !== 0,
encrypted: false,
byteOffset: null,
declaredSha256: null,
unpacked: false,
limitations:
kind === "symlink"
? ["Symlink target was not followed or disclosed."]
: [],
adapterKey: absolute,
...(kind === "file"
? {
sourceIdentity: { device: metadata.dev, inode: metadata.ino },
}
: {}),
};
if (kind === "directory" && (descendInto?.(path) ?? true))
directories.push(absolute);
}
} catch (cause: unknown) {
if (cause instanceof ArtifactReaderFailure) throw cause;
throw directoryIoFailure("read directory", directory, cause);
} finally {
// best-effort cleanup: directory-handle close must not mask traversal.
await handle.close().catch(() => undefined);
}
for await (const entry of this.#readDirectory(
directory,
pending,
signal,
descendInto,
))
yield entry;
await withDirectoryIoContext("inspect directory", directory, () =>
assertContainedDirectory(root, directory),
);
directories.reverse();
pending.push(...directories);
}
}
async #resolveRoot(): Promise<string> {
return (this.#rootPromise ??= realpath(this.#rootPath)).catch(
(cause: unknown) => {
throw directoryIoFailure(
"resolve artifact root",
this.#rootPath,
cause,
);
},
);
}
async *#readDirectory(
directory: string,
pending: string[],
signal?: AbortSignal,
descendInto?: (path: string) => boolean,
): AsyncIterable<ArtifactEntry> {
const root = await this.#resolveRoot();
const handle = new OwnedDirectoryHandle(
await opendir(directory).catch((cause: unknown) => {
throw directoryIoFailure("open directory", directory, cause);
}),
);
this.#directories.add(handle);
const childDirectories: string[] = [];
let traversal:
| { readonly kind: "complete" }
| { readonly kind: "failed"; readonly cause: unknown } = {
kind: "complete",
};
try {
for (;;) {
const child = await handle.handle.read();
if (child === null) break;
abortIfNeeded(signal);
const absolute = join(directory, child.name);
const metadata = await lstat(absolute).catch((cause: unknown) => {
throw directoryIoFailure("inspect entry", absolute, cause);
});
const path = relative(root, absolute).split(sep).join("/");
const entry = artifactEntry(path, absolute, metadata);
if (entry === undefined) continue;
yield entry;
if (entry.kind === "directory" && (descendInto?.(path) ?? true))
childDirectories.push(absolute);
}
} catch (cause: unknown) {
traversal = {
kind: "failed",
cause:
cause instanceof ArtifactReaderFailure
? cause
: directoryIoFailure("read directory", directory, cause),
};
} finally {
await this.#finishDirectory(handle, directory, traversal);
}
if (traversal.kind === "failed") throw traversal.cause;
childDirectories.reverse();
pending.push(...childDirectories);
}
async #finishDirectory(
handle: OwnedDirectoryHandle,
path: string,
traversal:
| { readonly kind: "complete" }
| { readonly kind: "failed"; readonly cause: unknown },
): Promise<void> {
try {
await handle.close();
this.#directories.delete(handle);
} catch (cause: unknown) {
const closeFailure = directoryIoFailure("close directory", path, cause);
if (traversal.kind === "failed")
throw ArtifactReaderFailure.withCleanup(
traversal.cause,
ArtifactReaderFailure.cleanupObservation(closeFailure, path),
);
throw closeFailure;
}
}
@@ -102,14 +147,16 @@ export class DirectoryArtifactReader implements ArtifactReader {
"format",
"Only regular files can be opened",
);
const handle = await open(
const fileHandle = await open(
entry.adapterKey,
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
).catch((cause: unknown) => {
throw directoryIoFailure("open entry", entry.adapterKey, cause);
});
const owner = new OwnedFileHandle(fileHandle);
this.#files.set(owner, entry.adapterKey);
try {
const observed = await handle.stat().catch((cause: unknown) => {
const observed = await owner.handle.stat().catch((cause: unknown) => {
throw directoryIoFailure(
"inspect opened entry",
entry.adapterKey,
@@ -127,16 +174,76 @@ export class DirectoryArtifactReader implements ArtifactReader {
`Directory entry changed before open: ${entry.path}`,
);
}
return handle.createReadStream({ autoClose: true });
abortIfNeeded(signal);
const source = Readable.from(
mapFileReadCancellation(owner.handle, signal),
{ objectMode: false },
);
source.once("close", () => {
void this.#closeFile(owner).catch(() => undefined);
});
return source;
} catch (cause: unknown) {
// best-effort cleanup: file-handle close must not mask the open failure.
await handle.close().catch(() => undefined);
try {
await this.#closeFile(owner);
} catch (closeCause: unknown) {
throw ArtifactReaderFailure.withCleanup(
cause,
ArtifactReaderFailure.cleanupObservation(
directoryIoFailure(
"close opened entry",
entry.adapterKey,
closeCause,
),
entry.adapterKey,
),
);
}
throw cause;
}
}
close(): Promise<void> {
return Promise.resolve();
async close(): Promise<void> {
const failures: unknown[] = [];
for (const [owner, path] of this.#files) {
try {
await this.#closeFile(owner);
} catch (cause: unknown) {
failures.push(directoryIoFailure("close opened entry", path, cause));
}
}
for (const owner of this.#directories) {
try {
await owner.close();
this.#directories.delete(owner);
} catch (cause: unknown) {
failures.push(
directoryIoFailure("close directory", owner.handle.path, cause),
);
}
}
if (failures.length > 0) {
const reason = failures.map(failureMessage).join("; ");
throw new ArtifactReaderFailure(
"io",
`Directory artifact cleanup failed: ${reason}`,
{
cause: new AggregateError(failures),
cleanup: {
reason,
resources: [
...this.#files.values(),
...[...this.#directories].map((owner) => owner.handle.path),
],
},
},
);
}
}
async #closeFile(owner: OwnedFileHandle<FileHandle>): Promise<void> {
await owner.close();
this.#files.delete(owner);
}
provenance(): readonly [] {
@@ -144,6 +251,74 @@ export class DirectoryArtifactReader implements ArtifactReader {
}
}
const artifactEntry = (
path: string,
absolute: string,
metadata: Stats,
): ArtifactEntry | undefined => {
const kind = metadata.isSymbolicLink()
? "symlink"
: metadata.isDirectory()
? "directory"
: metadata.isFile()
? "file"
: undefined;
if (kind === undefined) return undefined;
return {
path,
kind,
declaredSize: kind === "file" ? metadata.size : null,
compressedSize: null,
executable: (metadata.mode & 0o111) !== 0,
encrypted: false,
byteOffset: null,
declaredSha256: null,
unpacked: false,
limitations:
kind === "symlink"
? ["Symlink target was not followed or disclosed."]
: [],
adapterKey: absolute,
...(kind === "file"
? {
sourceIdentity: { device: metadata.dev, inode: metadata.ino },
}
: {}),
};
};
type DirectoryCloseState =
| { readonly kind: "open" }
| { readonly kind: "closing"; readonly attempt: Promise<void> }
| { readonly kind: "closed" }
| { readonly kind: "failed"; readonly cause: unknown };
/** Dir.close invalidates Node's private handle before its native close settles. */
class OwnedDirectoryHandle {
#state: DirectoryCloseState = { kind: "open" };
constructor(readonly handle: Dir) {}
close(): Promise<void> {
if (this.#state.kind === "closed") return Promise.resolve();
if (this.#state.kind === "closing") return this.#state.attempt;
if (this.#state.kind === "failed") return Promise.reject(this.#state.cause);
const attempt = Promise.resolve().then(async () => {
try {
await this.handle.close();
this.#state = { kind: "closed" };
} catch (cause: unknown) {
// Dir exposes no public validity signal; a second close can only lie
// with ERR_DIR_CLOSED after Node has invalidated the first owner.
this.#state = { kind: "failed", cause };
throw cause;
}
});
this.#state = { kind: "closing", attempt };
return attempt;
}
}
const directoryIoFailure = (
operation: string,
path: string,
@@ -157,13 +332,38 @@ const directoryIoFailure = (
typeof cause.code === "string"
? ` (${cause.code})`
: "";
const detail = failureMessage(cause);
return new ArtifactReaderFailure(
"io",
`Could not ${operation} at ${path}${code}`,
`Could not ${operation} at ${path}${code}: ${detail}`,
{ cause },
);
};
const failureMessage = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
const mapFileReadCancellation = async function* (
handle: FileHandle,
signal?: AbortSignal,
): AsyncGenerator<Buffer> {
try {
for await (const chunk of readFileHandleChunks(handle, { signal })) {
abortIfNeeded(signal);
yield chunk;
}
abortIfNeeded(signal);
} catch (cause: unknown) {
if (signal?.aborted === true)
throw new ArtifactReaderFailure(
"cancelled",
"Directory artifact read cancelled",
{ cause },
);
throw cause;
}
};
const withDirectoryIoContext = async <T>(
operation: string,
path: string,
+37 -5
View File
@@ -12,6 +12,7 @@ import {
type LipoArchitecture,
} from "../native/parsers/lipo.js";
import type { MachoSlice } from "../domain/apple/dylibResolution.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { readMachoImage, type ReadAt } from "./apple/MachoLoadCommandReader.js";
import {
ArtifactReaderFailure,
@@ -22,6 +23,8 @@ import {
} from "./ArtifactReader.js";
import { streamChunkToBuffer } from "./StreamBytes.js";
import {
NonRegularFileReadError,
RegularFileAdmissionFailure,
openRegularFile,
sameRegularFileState,
type StableRegularFileDescriptor,
@@ -33,6 +36,7 @@ export class MachOSliceArtifactReader implements ArtifactReader {
readonly format = "file" as const;
#command: ArtifactCommand | undefined;
#source: StableRegularFileDescriptor | undefined;
#ownedSource: OwnedFileHandle | undefined;
#pendingSource: Promise<StableRegularFileDescriptor> | undefined;
readonly #entries = new Map<string, ArtifactEntry>();
readonly #ownsSource: boolean;
@@ -283,23 +287,51 @@ export class MachOSliceArtifactReader implements ArtifactReader {
}
async #acquireSource(): Promise<StableRegularFileDescriptor> {
const handle = await openRegularFile(this.path, { symlinks: "reject" });
let handle: FileHandle;
try {
handle = await openRegularFile(this.path, { symlinks: "reject" });
} catch (cause: unknown) {
if (!(cause instanceof RegularFileAdmissionFailure)) throw cause;
this.#ownedSource = cause.owner;
const primary =
cause.cause instanceof NonRegularFileReadError
? new ArtifactReaderFailure(
"format",
`Universal Mach-O source is not a regular file: ${this.path}`,
{ cause: cause.cause },
)
: cause.cause;
throw ArtifactReaderFailure.withCleanup(
primary,
ArtifactReaderFailure.cleanupObservation(cause.cleanupCause, this.path),
);
}
const owner = new OwnedFileHandle(handle);
this.#ownedSource = owner;
try {
const initial = await handle.stat();
const source = { handle, initial };
this.#source = source;
return source;
} catch (cause: unknown) {
await handle.close().catch(() => undefined);
try {
await owner.close();
this.#ownedSource = undefined;
} catch (cleanupCause: unknown) {
throw ArtifactReaderFailure.withCleanup(
cause,
ArtifactReaderFailure.cleanupObservation(cleanupCause, this.path),
);
}
throw cause;
}
}
async #closeSource(): Promise<void> {
await this.#pendingSource?.catch(() => undefined);
if (!this.#ownsSource || this.#source === undefined) return;
const source = this.#source;
await source.handle.close();
if (!this.#ownsSource || this.#ownedSource === undefined) return;
await this.#ownedSource.close();
this.#ownedSource = undefined;
this.#source = undefined;
}
+27 -3
View File
@@ -12,8 +12,10 @@ import {
type ArtifactReader,
} from "./ArtifactReader.js";
import type { ZipPackageFormat } from "../domain/zipPackageFormat.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import {
NonRegularFileReadError,
RegularFileAdmissionFailure,
openRegularFile,
sameRegularFileState,
type StableRegularFileDescriptor,
@@ -21,6 +23,7 @@ import {
class NodeFileReader extends Reader<string> {
#handle: FileHandle | undefined;
#owner: OwnedFileHandle | undefined;
#initPromise: Promise<void> | undefined;
readonly #ownsHandle: boolean;
@@ -40,10 +43,30 @@ class NodeFileReader extends Reader<string> {
async #initialize(): Promise<void> {
try {
this.#handle =
const handle =
this.admitted?.handle ??
(await openRegularFile(this.path, { symlinks: "reject" }));
this.#handle = handle;
if (this.#ownsHandle) this.#owner = new OwnedFileHandle(handle);
} catch (cause: unknown) {
if (cause instanceof RegularFileAdmissionFailure) {
this.#owner = cause.owner;
const primary =
cause.cause instanceof NonRegularFileReadError
? new ArtifactReaderFailure(
"format",
`ZIP source is not a regular file: ${this.path}`,
{ cause: cause.cause },
)
: cause.cause;
throw ArtifactReaderFailure.withCleanup(
primary,
ArtifactReaderFailure.cleanupObservation(
cause.cleanupCause,
this.path,
),
);
}
if (cause instanceof NonRegularFileReadError)
throw new ArtifactReaderFailure(
"format",
@@ -88,8 +111,9 @@ class NodeFileReader extends Reader<string> {
async closeHandle(): Promise<void> {
if (!this.#ownsHandle) return;
await this.#initPromise?.catch(() => undefined);
if (this.#handle === undefined) return;
await this.#handle.close();
if (this.#owner === undefined) return;
await this.#owner.close();
this.#owner = undefined;
this.#handle = undefined;
}
}
@@ -1,7 +1,9 @@
import { execFile } from "node:child_process";
import { writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
@@ -12,6 +14,8 @@ import {
} from "../../../src/process/ProviderProcess.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const run = promisify(execFile);
it.skipIf(process.platform === "win32")(
"rejects a FIFO replacement without blocking the owner process",
async () => {
@@ -51,3 +55,46 @@ it.skipIf(process.platform === "win32")(
}
},
);
it.skipIf(process.platform === "win32")(
"skips unsupported FIFO entries and continues through the directory",
async () => {
const root = await createTestTempDirectory("rea-directory-entry-fifo-");
await writeFile(join(root, "module.js"), "export {}\n");
await run("mkfifo", [join(root, "unsupported.fifo")]);
const probe = fileURLToPath(
new URL(
"../../fixtures/directoryReaderUnsupportedEntryProbe.mjs",
import.meta.url,
),
);
const readerModule = fileURLToPath(
new URL(
"../../../dist/artifacts/DirectoryArtifactReader.js",
import.meta.url,
),
);
const launch = await spawnOwnedProviderProcess({
command: process.execPath,
arguments: [probe, readerModule, root],
runId: `rea-directory-entry-fifo-${randomUUID()}`,
cwd: tmpdir(),
hostEnvironment: process.env,
});
const supervisor = new ProviderProcessSupervisor({
...launch,
ownsProcessLifetime: true,
});
try {
expect(await supervisor.waitForOutputClose(2_000)).toBe(true);
expect(supervisor.snapshot().stdout.text.trim()).toBe('["module.js"]');
} finally {
const stopped = await supervisor.stop({
terminationGraceMs: 100,
killGraceMs: 500,
});
expect(stopped.status).not.toBe("incomplete");
}
},
);
@@ -0,0 +1,216 @@
import { writeFile, type FileHandle } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const controls = vi.hoisted(() => ({
wrapDirectory: undefined as
| ((handle: { close(): Promise<void>; readonly path: string }) => void)
| undefined,
wrapFile: undefined as
| ((handle: { close(): Promise<void>; readonly fd: number }) => void)
| undefined,
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
opendir: async (...args: Parameters<typeof actual.opendir>) => {
const handle = await actual.opendir(...args);
controls.wrapDirectory?.(handle);
return handle;
},
open: async (...args: Parameters<typeof actual.open>) => {
const handle = await actual.open(...args);
controls.wrapFile?.(handle);
return handle;
},
};
});
import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js";
import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const makeFixture = async () => {
const root = await createTestTempDirectory("rea-directory-owner-");
const file = join(root, "sample.txt");
await writeFile(file, "directory content\n");
return { root, file, reader: new DirectoryArtifactReader(root) };
};
afterEach(() => {
controls.wrapDirectory = undefined;
controls.wrapFile = undefined;
vi.restoreAllMocks();
});
describe("DirectoryArtifactReader resource ownership", () => {
it("closes the exact directory owner on early iterator return", async () => {
const { reader } = await makeFixture();
let closeCalls = 0;
controls.wrapDirectory = (handle) => {
const close = handle.close.bind(handle);
handle.close = async () => {
closeCalls += 1;
await close();
};
};
try {
const entries = reader.entries()[Symbol.asyncIterator]();
expect((await entries.next()).done).toBe(false);
await entries.return?.();
expect(closeCalls).toBe(1);
await reader.close();
expect(closeCalls).toBe(1);
} finally {
await reader.close();
}
});
it("retains an invalidated directory owner after native close rejection", async () => {
const { reader } = await makeFixture();
const nativeFailure = new Error("injected native directory close failure");
let closeCalls = 0;
controls.wrapDirectory = (handle) => {
const close = handle.close.bind(handle);
handle.close = async () => {
closeCalls += 1;
await close();
throw nativeFailure;
};
};
try {
await expect(async () => {
for await (const _entry of reader.entries()) {
// Read through EOF so the explicit owner close runs.
}
}).rejects.toMatchObject({
reason: "io",
message: expect.stringContaining("close directory"),
});
const cleanupFailure = await reader
.close()
.catch((cause: unknown) => cause);
expect(cleanupFailure).toBeInstanceOf(ArtifactReaderFailure);
expect((cleanupFailure as Error).message).toContain("cleanup failed");
expect(closeCalls).toBe(1);
const aggregate = (cleanupFailure as Error & { cause: AggregateError })
.cause;
expect(
aggregate.errors.some(
(cause) => cause instanceof Error && cause.cause === nativeFailure,
),
).toBe(true);
} finally {
await reader.close().catch(() => undefined);
}
});
it("preserves cancellation while retaining a failed directory close", async () => {
const { root, reader } = await makeFixture();
await writeFile(join(root, "second.txt"), "second entry\n");
const closeFailure = new Error("injected close after cancellation");
let closeCalls = 0;
controls.wrapDirectory = (handle) => {
const close = handle.close.bind(handle);
handle.close = async () => {
closeCalls += 1;
await close();
throw closeFailure;
};
};
try {
const controller = new AbortController();
const entries = reader.entries(controller.signal)[Symbol.asyncIterator]();
expect((await entries.next()).done).toBe(false);
controller.abort();
const failure = await entries.next().catch((cause: unknown) => cause);
expect(failure).toMatchObject({ reason: "cancelled" });
expect((failure as ArtifactReaderFailure).cleanup?.resources).toEqual([
root,
]);
expect(closeCalls).toBe(1);
await expect(reader.close()).rejects.toMatchObject({ reason: "io" });
expect(closeCalls).toBe(1);
} finally {
await reader.close().catch(() => undefined);
}
});
});
describe("DirectoryArtifactReader file handle ownership", () => {
it("retries an uninvalidated file close through its retained owner", async () => {
const { reader } = await makeFixture();
let fileCloseCalls = 0;
let openedHandle: FileHandle | undefined;
controls.wrapFile = (handle) => {
if (handle.fd < 0) return;
openedHandle = handle as FileHandle;
const close = handle.close.bind(handle);
handle.close = async () => {
fileCloseCalls += 1;
if (fileCloseCalls === 1) throw new Error("before native close");
await close();
};
};
try {
let entry;
for await (const observed of reader.entries()) {
if (observed.path === "sample.txt") entry = observed;
}
if (entry === undefined)
throw new Error("fixture file was not inventoried");
const stream = await reader.open(entry);
stream.destroy();
await new Promise<void>((resolve) => stream.once("close", resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
expect(fileCloseCalls).toBe(1);
expect(openedHandle?.fd).toBeGreaterThanOrEqual(0);
await reader.close();
expect(fileCloseCalls).toBe(2);
expect(openedHandle?.fd).toBe(-1);
} finally {
await reader.close().catch(() => undefined);
}
});
it("maps an aborted positional read to typed cancellation and owner close", async () => {
const { file, reader } = await makeFixture();
await writeFile(file, Buffer.alloc(256 * 1024, 0x5a));
try {
let entry;
for await (const observed of reader.entries()) {
if (observed.path === "sample.txt") entry = observed;
}
if (entry === undefined)
throw new Error("fixture file was not inventoried");
const controller = new AbortController();
const stream = await reader.open(entry, controller.signal);
const closed = new Promise<void>((resolve) =>
stream.once("close", resolve),
);
const outcome = new Promise<unknown>((resolve, reject) => {
stream.once("error", resolve);
stream.once("end", () => reject(new Error("read was not cancelled")));
});
stream.once("data", () => controller.abort());
const failure = await outcome;
expect(failure).toMatchObject({
name: "ArtifactReaderFailure",
reason: "cancelled",
});
await closed;
await new Promise<void>((resolve) => setImmediate(resolve));
await reader.close();
} finally {
await reader.close().catch(() => undefined);
}
});
});
@@ -0,0 +1,140 @@
import type { FileHandle } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished, vi } from "vitest";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import { MachOSliceArtifactReader } from "../../../src/artifacts/MachOSliceArtifactReader.js";
import { ZipArtifactReader } from "../../../src/artifacts/ZipArtifactReader.js";
import { ok } from "../../../src/domain/result.js";
import type { NativeCommandRunner } from "../../../src/native/CommandRunner.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const injected = vi.hoisted(() => ({
opened: undefined as
| ((path: unknown, handle: FileHandle) => void)
| undefined,
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
open: async (...args: Parameters<typeof actual.open>) => {
const handle = await actual.open(...args);
injected.opened?.(args[0], handle);
return handle;
},
};
});
const runner: NativeCommandRunner = {
run: async () =>
ok({
tool: "lipo",
executable: "/usr/bin/lipo",
executableSha256: "1".repeat(64),
toolVersion: null,
versionReason: "fixture",
arguments: [],
stdout: "",
stderr: "",
stdoutBytes: 0,
stderrBytes: 0,
exitCode: 0,
signal: null,
}),
};
const readers = [
["ZIP", (path: string) => new ZipArtifactReader(path, "zip")],
[
"Mach-O slice",
(path: string) => new MachOSliceArtifactReader(path, {}, runner),
],
] as const;
it.each(readers)(
"retains %s reader admission and stat owners",
async (_, createReader) => {
const root = await createTestTempDirectory("rea-owned-reader-cleanup-");
const path = join(root, "source");
await writeFile(path, "fixture source");
for (const stage of ["admission", "post-open-stat"] as const) {
const statFailure = Object.assign(
new Error(`primary ${stage} stat failure`),
{ code: "EIO", errno: -5, syscall: "fstat" },
);
const closeFailure = new Error(
`close failed after invalidation during ${stage}`,
);
let descriptor: FileHandle | undefined;
let statCalls = 0;
let closeAttempts = 0;
let failOnStat = stage === "admission" ? 1 : 2;
injected.opened = (openedPath, handle) => {
if (openedPath !== path || descriptor !== undefined) return;
descriptor = handle;
const stat = handle.stat.bind(handle);
const close = handle.close.bind(handle);
vi.spyOn(handle, "stat").mockImplementation(async (...args) => {
statCalls += 1;
if (statCalls === failOnStat) {
failOnStat = -1;
throw statFailure;
}
return stat(...args);
});
vi.spyOn(handle, "close").mockImplementation(async () => {
closeAttempts += 1;
await close();
throw closeFailure;
});
};
const reader = createReader(path);
const scope = new ArtifactResourceScope();
const owner = { kind: "reader" as const, resource: path, reader };
onTestFinished(async () => {
injected.opened = undefined;
await scope.close().catch(() => undefined);
vi.restoreAllMocks();
});
const primary = await (async () => {
try {
for await (const _entry of reader.entries()) {
// Both injected failures happen before the reader can yield an entry.
}
} catch (cause: unknown) {
return cause;
}
throw new Error("Expected reader initialization to fail");
})();
expect(primary).toBeInstanceOf(Error);
expect(causeChainContains(primary, statFailure)).toBe(true);
const release = await scope.release(owner);
expect(release).toEqual({ kind: "failed", cause: closeFailure });
expect(descriptor?.fd).toBe(-1);
await expect(scope.close()).rejects.toMatchObject({
reason: "unavailable",
cleanup: { reason: closeFailure.message, resources: [path] },
});
// Native close invalidated the fd before rejecting. Retrying the same owner
// must remain failed without another syscall or a false successful release.
expect(closeAttempts).toBe(1);
}
},
);
const causeChainContains = (cause: unknown, expected: unknown): boolean => {
let current: unknown = cause;
while (current instanceof Error) {
if (current === expected) return true;
current = current.cause;
}
return false;
};
+15
View File
@@ -0,0 +1,15 @@
import { pathToFileURL } from "node:url";
const [modulePath, root] = process.argv.slice(2);
if (modulePath === undefined || root === undefined)
throw new Error("Directory reader probe requires module and root paths");
const { DirectoryArtifactReader } = await import(pathToFileURL(modulePath));
const reader = new DirectoryArtifactReader(root);
const paths = [];
try {
for await (const entry of reader.entries()) paths.push(entry.path);
} finally {
await reader.close();
}
process.stdout.write(`${JSON.stringify(paths)}\n`);