Files
rea/tests/process-global/dylibFileLifetime.test.ts
T
morluto d292c445ee fix(apple): share dylib read ownership and cancellation facts
Refs #4495.

Normalize dylib file access once at the admitted descriptor boundary. Preserve full file identity, physical read outcomes, cancellation provenance and cleanup retry through artifact analysis.
2026-10-17 00:44:02 +08:00

384 lines
14 KiB
TypeScript

import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { mkdir, rename, writeFile, type FileHandle } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
const fault = vi.hoisted(() => ({
path: "",
handles: [] as FileHandle[],
blocked: new Set<FileHandle>(),
blockClose: false,
readFailure: undefined as Error | undefined,
controller: undefined as AbortController | undefined,
abortOnRead: false,
abortClose: 0,
closeCount: 0,
onClose: undefined as (() => Promise<void>) | undefined,
beforeOpen: undefined as (() => Promise<void>) | undefined,
onRead: undefined as (() => Promise<void>) | undefined,
}));
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>) => {
if (args[0] === fault.path) await fault.beforeOpen?.();
const handle = await original.open(...args);
if (args[0] !== fault.path) return handle;
fault.handles.push(handle);
if (fault.blockClose) fault.blocked.add(handle);
const read = handle.read.bind(handle);
handle.read = (async (...readArgs: unknown[]) => {
if (fault.abortOnRead) fault.controller?.abort(null);
if (fault.readFailure !== undefined) throw fault.readFailure;
const result = await Reflect.apply(read, handle, readArgs);
await fault.onRead?.();
return result;
}) as FileHandle["read"];
const close = handle.close.bind(handle);
handle.close = async () => {
fault.closeCount += 1;
await fault.onClose?.();
if (fault.closeCount === fault.abortClose)
fault.controller?.abort(null);
if (fault.blocked.has(handle))
throw new Error("controlled dyld close failure");
await close();
};
return handle;
},
};
});
import { traceDylibResolution } from "../../src/artifacts/apple/DylibResolutionReader.js";
import { machoImage } from "../../src/artifacts/apple/MachoImage.fixture.js";
import { ArtifactFileCleanupFailure } from "../../src/artifacts/ArtifactFileOperation.js";
import { ArtifactReaderFailure } from "../../src/artifacts/ArtifactReader.js";
import { ArtifactProvider } from "../../src/artifacts/ArtifactProvider.js";
import type { AnalysisClient } from "../../src/application/AnalysisProvider.js";
import { parseBinaryTarget } from "../../src/application/BinaryTargetResolver.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
const clients: AnalysisClient[] = [];
beforeEach(() => {
fault.path = "";
fault.handles = [];
fault.blocked.clear();
fault.blockClose = false;
fault.readFailure = undefined;
fault.controller = undefined;
fault.abortOnRead = false;
fault.abortClose = 0;
fault.closeCount = 0;
fault.onClose = undefined;
fault.beforeOpen = undefined;
fault.onRead = undefined;
});
afterEach(async () => {
fault.blocked.clear();
fault.onClose = undefined;
for (const client of clients.splice(0)) await client.close();
for (const handle of fault.handles)
if (handle.fd !== -1) await handle.close();
});
const fixture = async () => {
const root = await createTestTempDirectory("rea-dyld-lifetime-");
const path = join(root, "Main");
const bytes = machoImage({});
await writeFile(path, bytes);
const target = await parseBinaryTarget(path);
if (!target.ok) throw target.error;
const controller = new AbortController();
fault.path = path;
fault.controller = controller;
const sha256 = createHash("sha256").update(bytes).digest("hex");
const client = new ArtifactProvider({}).createClient(target.value);
clients.push(client);
return {
controller,
client,
trace: () =>
traceDylibResolution({
rootPath: root,
targetPath: path,
targetSha256: sha256,
enumerateRoots: false,
parameters: { roots: ["Main"] },
signal: controller.signal,
}),
};
};
it("preserves a dyld read failure when the same read observes cancellation", async () => {
const { trace } = await fixture();
const cause = Object.assign(new Error("controlled dyld EIO"), {
code: "EIO",
});
fault.readFailure = cause;
fault.abortOnRead = true;
await expect(trace()).rejects.toBe(cause);
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
});
it("observes cancellation during the final dyld digest close", async () => {
const { trace } = await fixture();
fault.abortClose = 2;
await expect(trace()).rejects.toMatchObject({
reason: "cancelled",
cancellation: { reason: null },
});
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
});
it.each(["completed", "failed", "cancelled"] as const)(
"retains the exact dyld descriptor and %s outcome when close fails",
async (kind) => {
const { trace } = await fixture();
fault.blockClose = true;
const cause = new ArtifactReaderFailure("io", "controlled primary");
if (kind === "failed") fault.readFailure = cause;
if (kind === "cancelled") fault.abortClose = 1;
const failure = await trace().catch((error: unknown) => error);
expect(failure).toBeInstanceOf(ArtifactFileCleanupFailure);
if (!(failure instanceof ArtifactFileCleanupFailure))
throw new Error("Expected exact descriptor cleanup capability");
expect(failure.fileCleanupFailure.owner.handle).toBe(fault.handles[0]);
if (kind === "cancelled")
expect(failure).toMatchObject({
reason: "cancelled",
cancellation: { reason: null },
});
expect(failure.fileCleanupFailure.outcome.kind).toBe(
kind === "failed" ? "failed" : "completed",
);
if (kind === "failed")
expect(failure.fileCleanupFailure.outcome).toEqual({
kind: "failed",
cause,
});
fault.blocked.clear();
await failure.cleanupLease.release();
expect(fault.handles[0]?.fd).toBe(-1);
},
);
it("projects cancellation with cleanup uncertainty when dyld final close fails", async () => {
const { client, controller } = await fixture();
fault.abortClose = 1;
fault.blockClose = true;
const result = await client.execute(
"trace_dylib_resolution",
{ roots: ["Main"] },
{ signal: controller.signal },
);
if (result.ok) throw new Error("Expected interruption during finalization");
expect(result.error).toMatchObject({
reason: "cancelled",
cause: null,
cleanupIncomplete: true,
});
fault.blocked.clear();
await expect(client.close()).resolves.toMatchObject({ ok: true });
expect(result.error.cleanupIncomplete).toBe(false);
});
it.skipIf(process.platform === "win32")(
"retains nonregular admission classification and its exact failed close",
async () => {
const { client } = await fixture();
fault.beforeOpen = async () => {
fault.beforeOpen = undefined;
await rename(fault.path, `${fault.path}.old`);
await mkdir(fault.path);
};
fault.blockClose = true;
const result = await client.execute("trace_dylib_resolution", {
roots: ["Main"],
});
if (result.ok) throw new Error("Expected nonregular admission failure");
expect(result.error).toMatchObject({
reason: "format",
cleanupIncomplete: true,
});
expect(result.error.cause).toBeInstanceOf(ArtifactFileCleanupFailure);
expect(fault.handles).toHaveLength(1);
fault.blocked.clear();
await expect(client.close()).resolves.toMatchObject({ ok: true });
expect(result.error.cleanupIncomplete).toBe(false);
expect(fault.handles[0]?.fd).toBe(-1);
},
);
it.each(["EACCES", "EPERM"])(
"retains %s semantics alongside failed cleanup",
async (code) => {
const { client } = await fixture();
fault.readFailure = Object.assign(
new Error("controlled permission failure"),
{ code },
);
fault.blockClose = true;
const result = await client.execute("trace_dylib_resolution", {
roots: ["Main"],
});
if (result.ok) throw new Error("Expected permission failure");
expect(result.error).toMatchObject({
reason: "unavailable",
cleanupIncomplete: true,
});
expect(result.error.cause).toBeInstanceOf(ArtifactFileCleanupFailure);
fault.blocked.clear();
await expect(client.close()).resolves.toMatchObject({ ok: true });
expect(result.error.cleanupIncomplete).toBe(false);
},
);
it("rejects a substituted pathname after parsing the original descriptor", async () => {
const { trace } = await fixture();
fault.onRead = async () => {
fault.onRead = undefined;
await rename(fault.path, `${fault.path}.old`);
await writeFile(fault.path, machoImage({}));
};
await expect(trace()).rejects.toMatchObject({ reason: "integrity" });
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
});
it.skipIf(process.platform === "win32").each(["magic", "image", "digest"])(
"rejects a FIFO substituted at the compiled dyld %s open without a writer",
async (stage) => {
await fixture();
const path = fault.path;
fault.path = "";
const module = pathToFileURL(
join(process.cwd(), "dist/artifacts/apple/DylibResolutionReader.js"),
).href;
const script = `
import fs from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { dirname } from 'node:path';
const [stage, path] = process.argv.slice(1);
const sha256 = createHash('sha256').update(await fs.promises.readFile(path)).digest('hex');
const nativeOpen = fs.promises.open;
let opens = 0;
let substituted = false;
fs.promises.open = async (selected, ...args) => {
if (selected === path && ++opens === (stage === 'digest' ? 2 : 1)) {
await fs.promises.rename(path, path + '.old');
execFileSync('mkfifo', [path]);
substituted = true;
}
return await nativeOpen(selected, ...args);
};
syncBuiltinESMExports();
const { traceDylibResolution } = await import(${JSON.stringify(module)});
try {
await traceDylibResolution({ rootPath: dirname(path), targetPath: path,
targetSha256: sha256, enumerateRoots: stage === 'magic',
parameters: stage === 'magic' ? {} : { roots: ['Main'] } });
throw new Error('Unexpected FIFO success');
} catch (error) {
if (error.reason !== 'format') throw error;
process.stdout.write(JSON.stringify({ stage, opens, substituted, reason: error.reason }));
}
`;
const result = await promisify(execFile)(
process.execPath,
["--input-type=module", "--eval", script, stage, path],
{ timeout: 5000 },
);
expect(JSON.parse(result.stdout)).toEqual({
stage,
opens: stage === "digest" ? 2 : 1,
substituted: true,
reason: "format",
});
},
);
it("binds both dyld failure and failed provider-close projections to the same descriptor", async () => {
const { client } = await fixture();
fault.blockClose = true;
fault.readFailure = new ArtifactReaderFailure("io", "controlled primary");
const result = await client.execute("trace_dylib_resolution", {
roots: ["Main"],
});
if (result.ok) throw new Error("Expected failed dyld read");
expect(result.error).toMatchObject({ reason: "io", cleanupIncomplete: true });
expect(result.error.cause).toBeInstanceOf(ArtifactFileCleanupFailure);
const close = await client.close();
if (close.ok) throw new Error("Expected retained descriptor");
expect(close.error.cleanupIncomplete).toBe(true);
expect(fault.handles).toHaveLength(1);
fault.blocked.clear();
await expect(client.close()).resolves.toMatchObject({ ok: true });
expect(result.error.cleanupIncomplete).toBe(false);
expect(close.error.cleanupIncomplete).toBe(false);
expect(fault.handles[0]?.fd).toBe(-1);
});
it("preserves the caller's explicit null cancellation reason through dyld provider projection", async () => {
const { client, controller } = await fixture();
fault.abortOnRead = true;
fault.blockClose = true;
const result = await client.execute(
"trace_dylib_resolution",
{ roots: ["Main"] },
{ signal: controller.signal },
);
if (result.ok) throw new Error("Expected cancelled dyld read");
expect(result.error).toMatchObject({
reason: "cancelled",
cleanupIncomplete: true,
});
expect(result.error.cause).toBeNull();
fault.blocked.clear();
await expect(client.close()).resolves.toMatchObject({ ok: true });
expect(result.error.cleanupIncomplete).toBe(false);
expect(fault.handles[0]?.fd).toBe(-1);
});
it("waits for an in-flight final dyld close before completing provider close", async () => {
const { client, controller } = await fixture();
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
});
fault.onClose = async () => {
if (fault.closeCount === 2) await gate;
};
const running = client.execute(
"trace_dylib_resolution",
{ roots: ["Main"] },
{ signal: controller.signal },
);
try {
await expect.poll(() => fault.closeCount).toBe(2);
let closed = false;
const closing = client.close().then((result) => {
closed = true;
return result;
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(closed).toBe(false);
controller.abort(null);
release();
await expect(running).resolves.toMatchObject({
ok: false,
error: { reason: "cancelled", cause: null },
});
await expect(closing).resolves.toMatchObject({ ok: true });
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
} finally {
release();
await running;
}
});