fix(process)!: retain partial filesystem snapshot observations

Keep expected readlink and follow-up lstat failures as typed entry omissions,
including the observed path and errno. Retain directory metadata and sibling
observations, distinguish known identity replacements, and keep cancellation
and unexpected I/O failures terminal. Reuse admitted root metadata and test
cancellation by descriptor state rather than a count of named lookups.

Adapt the symlink race handling proposed in #4587 at fae4e25539.

Closes #4575

BREAKING CHANGE: Filesystem enumeration omission reasons now also include
entry_unavailable and identity_changed. Clients validating the old literal
reason must accept the expanded capture schema.

Co-authored-by: rudrakshagarwal4200-ai <rudrakshagarwal4200@gmail.com>
This commit is contained in:
morlutoandrudrakshagarwal4200-ai committed 2026-10-19 08:34:39 +08:00
1 parent 95c8f57aa2
commit c85b44e1b1
8 files changed
+826 -61

No files matched your search

+9
View File
@@ -241,6 +241,15 @@ follow also remain unknown. Partial enumeration of one root does not erase
known effects in another, and unavailable content hashes do not by themselves
make path absence unknown.
When a follow-up read fails after an entry's metadata was observed, the
snapshot keeps unaffected observations and records an `entry_unavailable`
enumeration omission with the root-aliased path and system error code.
Successfully observed identity replacements use `identity_changed` with a null
system code. Unstable symlink targets are omitted; already observed directory
metadata remains available. The affected root stays incomplete, so omitted
entries do not prove deletion. A path missing at its initial lookup remains an
observed absence. Cancellation and unexpected I/O failures still stop capture.
Terminal frame `data` is the comparison text after the selected normalization.
New captures also preserve `raw_data` when normalization changes a PTY chunk;
otherwise `data` is already the original text. Older captures may lack the
@@ -5,12 +5,94 @@ import { parseProcessCapture } from "./processCaptureParsing.js";
import { partialProcessCaptureObservationSchema } from "./processCapture.js";
import { FINALIZED_PROCESS_CAPTURE_EXAMPLE } from "./processCaptureExample.js";
import { finalizationConsistencyIssue } from "./processCaptureValidation.js";
import type { FilesystemCoverage } from "./processCaptureCoverage.js";
import {
compareUnverifiedProcessCaptures as compareProcessCaptures,
emptyUnverifiedProcessCapture as emptyCapture,
processCaptureIssues,
} from "./processCapture.fixture.js";
const captureWithEnumerationOmission = (
omission: FilesystemCoverage["enumeration_omissions"][number],
) => {
const capture = emptyCapture();
const scenario = {
...capture.manifest.scenario,
filesystem_observation_paths: ["/fixture/selected"],
};
return {
...capture,
manifest: {
...capture.manifest,
scenario,
full_scenario_sha256: digestProcessCommitment(scenario),
},
truncated: true,
filesystem_checkpoints: capture.filesystem_checkpoints.map((checkpoint) =>
checkpoint.name === "after_settlement"
? { ...checkpoint, truncated: true }
: checkpoint,
),
truncation_details: {
...capture.truncation_details,
filesystem_after: {
...capture.truncation_details.filesystem_after,
enumeration_truncated: true,
enumeration_reasons: [omission.reason],
enumeration_omissions: [omission],
},
},
};
};
it.each([
{ path: "root_0:.", reason: "entry_unavailable", system_code: "ENOENT" },
{ path: "root_0:.", reason: "identity_changed", system_code: null },
] as const)("accepts a partial entry with $reason evidence", (omission) => {
const capture = captureWithEnumerationOmission(omission);
expect(parseProcessCapture(capture)).toEqual(capture);
});
it.each([
{ path: "root_0:.", reason: "entry_unavailable", system_code: null },
{ path: "root_0:.", reason: "identity_changed", system_code: "ENOENT" },
{ path: "root_0:.", reason: "directory_unavailable", system_code: "ENOENT" },
] as const)("rejects an inconsistent omission %j", (omission) => {
expect(() =>
parseProcessCapture(captureWithEnumerationOmission(omission)),
).toThrow("enumeration_omissions");
});
it("requires entry-unavailability details and matching aggregate reasons", () => {
const capture = captureWithEnumerationOmission({
path: "root_0:.",
reason: "entry_unavailable",
system_code: "ENOENT",
});
for (const changes of [
{ enumeration_omissions: [] },
{ enumeration_reasons: ["identity_changed"] },
{
enumeration_omissions: [
...capture.truncation_details.filesystem_after.enumeration_omissions,
...capture.truncation_details.filesystem_after.enumeration_omissions,
],
},
])
expect(() =>
parseProcessCapture({
...capture,
truncation_details: {
...capture.truncation_details,
filesystem_after: {
...capture.truncation_details.filesystem_after,
...changes,
},
},
}),
).toThrow("enumeration_omissions");
});
it("never considers truncated captures equivalent", () => {
const complete = emptyCapture();
const capture = {
+8 -3
View File
@@ -11,6 +11,12 @@ const byteRetentionSchema = z.strictObject({
unknown_size_frames: z.number().int().nonnegative().optional(),
});
const enumerationOmissionReasonSchema = z.enum([
"identity_changed",
"directory_unavailable",
"entry_unavailable",
]);
/** Filesystem enumeration and whole-file digest coverage for one checkpoint. */
export const filesystemCoverageSchema = z.strictObject({
files_limit: z.number().int().nonnegative(),
@@ -20,14 +26,13 @@ export const filesystemCoverageSchema = z.strictObject({
z.enum([
"files_limit",
"depth_limit",
"identity_changed",
"directory_unavailable",
...enumerationOmissionReasonSchema.options,
]),
),
enumeration_omissions: z.array(
z.strictObject({
path: z.string(),
reason: z.literal("directory_unavailable"),
reason: enumerationOmissionReasonSchema,
system_code: z.string().nullable(),
}),
),
+15 -7
View File
@@ -360,9 +360,7 @@ const validateCoverage = (
.filter((file) => file.type === "file" && file.sha256 === null)
.map((file) => [file.path, file]),
);
const directories = new Set(
files.filter((file) => file.type === "directory").map(({ path }) => path),
);
const entryTypes = new Map(files.map(({ path, type }) => [path, type]));
require(coverage.hash_omissions.length === unhashedFiles.size &&
new Set(coverage.hash_omissions.map(({ path }) => path)).size ===
unhashedFiles.size &&
@@ -376,12 +374,22 @@ const validateCoverage = (
), `truncation_details.${name}.hash_omissions`, "every retained regular file without a digest must have one matching omission reason");
require(new Set(coverage.enumeration_omissions.map(({ path }) => path))
.size === coverage.enumeration_omissions.length &&
(coverage.enumeration_omissions.length === 0 ||
coverage.enumeration_reasons.includes("directory_unavailable")) &&
(!coverage.enumeration_reasons.includes("entry_unavailable") ||
coverage.enumeration_omissions.some(
({ reason }) => reason === "entry_unavailable",
)) &&
coverage.enumeration_omissions.every(
(omission) =>
directories.has(omission.path) && omission.system_code !== null,
), `truncation_details.${name}.enumeration_omissions`, "every directory enumeration omission must identify one retained directory and its system error");
coverage.enumeration_reasons.includes(omission.reason) &&
(omission.reason === "directory_unavailable"
? entryTypes.get(omission.path) === "directory" &&
omission.system_code !== null
: (entryTypes.get(omission.path) === undefined ||
entryTypes.get(omission.path) === "directory") &&
(omission.reason === "entry_unavailable"
? omission.system_code !== null
: omission.system_code === null)),
), `truncation_details.${name}.enumeration_omissions`, "every enumeration omission must identify an unavailable entry or retained directory, with a matching reason and system error only for unavailable reads");
require(coverage.hashed_bytes ===
files
.filter((file) => file.type === "file" && file.sha256 !== null)
+94 -41
View File
@@ -6,6 +6,7 @@ import type { FileHandle } from "node:fs/promises";
import type { ProcessScenario } from "../../domain/process/processScenario.js";
import type { FilesystemCoverage } from "../../domain/process/processCaptureCoverage.js";
import type { Stats } from "node:fs";
import { err, ok, type Result } from "../../domain/result.js";
import type {
FileState,
ProcessFilesystemSnapshot,
@@ -30,23 +31,70 @@ const hasSameFileState = (before: Stats, after: Stats): boolean =>
before.mtimeMs === after.mtimeMs &&
before.ctimeMs === after.ctimeMs;
const fileUnavailableSystemCode = (cause: unknown): string | undefined => {
const filesystemSystemCode = (cause: unknown): string | undefined => {
if (!(cause instanceof Error) || !("code" in cause)) return undefined;
const code = cause.code;
return typeof code === "string" &&
[
"EACCES",
"EISDIR",
"ELOOP",
"ENOTDIR",
"ENOENT",
"EPERM",
"ESTALE",
].includes(code)
return typeof code === "string" ? code : undefined;
};
const unavailableSystemCodes = new Set([
"EACCES",
"EISDIR",
"ELOOP",
"ENOTDIR",
"ENOENT",
"EPERM",
"ESTALE",
]);
const fileUnavailableSystemCode = (cause: unknown): string | undefined => {
const code = filesystemSystemCode(cause);
return code !== undefined && unavailableSystemCodes.has(code)
? code
: undefined;
};
type EntryOmission =
| { readonly reason: "entry_unavailable"; readonly system_code: string }
| { readonly reason: "identity_changed"; readonly system_code: null };
/** A follow-up read cannot turn previously observed metadata into absence. */
const readEntryObservation = async <T>(
read: () => Promise<T>,
signal?: AbortSignal,
): Promise<Result<T, EntryOmission>> => {
signal?.throwIfAborted();
try {
const value = await read();
signal?.throwIfAborted();
return ok(value);
} catch (cause: unknown) {
signal?.throwIfAborted();
const code = filesystemSystemCode(cause);
// readlink reports EINVAL if the observed symlink was replaced by a file.
if (
code === undefined ||
(!unavailableSystemCodes.has(code) && code !== "EINVAL")
)
throw cause;
return err({ reason: "entry_unavailable", system_code: code });
}
};
const observeSymlink = async (
path: string,
before: Stats,
signal?: AbortSignal,
): Promise<Result<string, EntryOmission>> => {
const target = await readEntryObservation(() => readlink(path), signal);
if (!target.ok) return target;
const after = await readEntryObservation(() => lstat(path), signal);
if (!after.ok) return after;
return hasSameIdentity(before, after.value, "symlink")
? target
: err({ reason: "identity_changed", system_code: null });
};
const lstatIfPresent = async (
path: string,
previouslyObservedRoot = false,
@@ -58,13 +106,9 @@ const lstatIfPresent = async (
signal?.throwIfAborted();
return stats;
} catch (cause: unknown) {
if (
cause instanceof Error &&
"code" in cause &&
(cause.code === "ENOENT" ||
(previouslyObservedRoot && cause.code === "ENOTDIR"))
) {
signal?.throwIfAborted();
signal?.throwIfAborted();
const code = filesystemSystemCode(cause);
if (code === "ENOENT" || (previouslyObservedRoot && code === "ENOTDIR")) {
return undefined;
}
throw cause;
@@ -198,11 +242,18 @@ export const snapshotRoots = async (
>();
const enumerationOmissions: FilesystemCoverage["enumeration_omissions"] = [];
const hashOmissions: FilesystemCoverage["hash_omissions"] = [];
const omitEntry = (path: string, omission: EntryOmission): false => {
truncated = true;
enumerationReasons.add(omission.reason);
enumerationOmissions.push({ path, ...omission });
return false;
};
const visit = async (
root: string,
rootAlias: string,
path: string,
depth: number,
admitted?: Stats,
): Promise<boolean> => {
signal?.throwIfAborted();
if (
@@ -216,29 +267,23 @@ export const snapshotRoots = async (
enumerationReasons.add("depth_limit");
return false;
}
const stats = await lstatIfPresent(
path,
previousPaths.has(`${rootAlias}:.`),
signal,
);
const stats =
admitted ??
(await lstatIfPresent(path, previousPaths.has(`${rootAlias}:.`), signal));
if (stats === undefined) return true;
const relativePath = relative(root, path) || ".";
if (stats.isSymbolicLink()) {
// Keep the target as stored: lexical resolution can merge distinct targets.
const target = await readlink(path);
const afterRead = await lstat(path);
if (!hasSameIdentity(stats, afterRead, "symlink")) {
truncated = true;
enumerationReasons.add("identity_changed");
return false;
}
const target = await observeSymlink(path, stats, signal);
if (!target.ok)
return omitEntry(`${rootAlias}:${relativePath}`, target.error);
entries.push({
path: `${rootAlias}:${relativePath}`,
type: "symlink",
mode: stats.mode,
size: stats.size,
sha256: null,
symlink_target: target,
symlink_target: target.value,
});
return true;
}
@@ -330,12 +375,18 @@ export const snapshotRoots = async (
children.push(child.name);
pendingNames += 1;
}
const afterRead = await lstat(path);
if (!hasSameIdentity(stats, afterRead, "directory")) {
const afterRead = await readEntryObservation(() => lstat(path), signal);
if (
!afterRead.ok ||
!hasSameIdentity(stats, afterRead.value, "directory")
) {
pendingNames -= children.length;
truncated = true;
enumerationReasons.add("identity_changed");
return false;
return omitEntry(
`${rootAlias}:${relativePath}`,
afterRead.ok
? { reason: "identity_changed", system_code: null }
: afterRead.error,
);
}
children.sort().reverse();
while (children.length > 0) {
@@ -349,14 +400,16 @@ export const snapshotRoots = async (
};
for (const [index, root] of scenario.filesystem_observation_paths.entries()) {
const alias = `root_${String(index)}`;
if (
(await lstatIfPresent(root, previousPaths.has(`${alias}:.`), signal)) ===
undefined
) {
const admitted = await lstatIfPresent(
root,
previousPaths.has(`${alias}:.`),
signal,
);
if (admitted === undefined) {
completeRoots.push(alias);
continue;
}
if (await visit(root, alias, root, 0)) completeRoots.push(alias);
if (await visit(root, alias, root, 0, admitted)) completeRoots.push(alias);
}
return {
files: entries,
@@ -0,0 +1,166 @@
import { lstat, mkdir, readFile, symlink, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import { CAPTURE_SKIP_REASON } from "./processCaptureCapability.js";
const captureCliTest = cliTest.skipIf(
CAPTURE_SKIP_REASON ||
(process.platform === "win32" ? "requires POSIX symlink semantics" : false),
);
const racePreload = `
import fs from "node:fs";
import { resolve } from "node:path";
import { syncBuiltinESMExports } from "node:module";
const cliEntry = process.env.REA_TEST_CLI_ENTRY;
if (cliEntry !== undefined && resolve(process.argv[1] ?? "") === cliEntry) {
const target = resolve(process.env.REA_TEST_SYMLINK ?? "");
const countPath = process.env.REA_TEST_READLINK_COUNT;
const removeOnSecondRead = process.env.REA_TEST_REMOVE_ON_SECOND_READ === "1";
let matchingReads = 0;
const originalReadlink = fs.promises.readlink;
fs.promises.readlink = async function (path, ...options) {
if (resolve(String(path)) === target) {
matchingReads += 1;
if (countPath !== undefined) fs.appendFileSync(countPath, "read\\n");
if (removeOnSecondRead && matchingReads === 2) {
await fs.promises.unlink(path);
}
}
return originalReadlink.call(this, path, ...options);
};
syncBuiltinESMExports();
}
`;
const createScenario = async (removeOnSecondRead: boolean) => {
const root = await createTestTempDirectory("rea-snapshot-race-cli-");
const observed = join(root, "observed");
const link = join(observed, "vanishing-link");
const scenario = join(root, "scenario.json");
const preload = join(root, "readlink-race-preload.mjs");
const readlinkCount = join(root, "readlink-count.txt");
await mkdir(observed);
await writeFile(join(observed, "sibling.txt"), "sibling evidence\n");
await symlink("target-does-not-need-to-exist", link);
await writeFile(preload, racePreload);
await writeFile(
scenario,
JSON.stringify({
executable: process.execPath,
arguments: ["-e", "process.stdout.write('controlled capture\\n')"],
working_directory: root,
filesystem_observation_paths: [observed],
}),
);
return { root, link, scenario, preload, readlinkCount, removeOnSecondRead };
};
captureCliTest(
"keeps a stable symlink observation complete through the public CLI",
async ({ cli }) => {
const fixture = await createScenario(false);
const result = await cli.run({
arguments: ["capture-process", fixture.scenario, "--json"],
cwd: fixture.root,
environment: {
NODE_OPTIONS: `--import=${pathToFileURL(fixture.preload).href}`,
REA_TEST_CLI_ENTRY: resolve("scripts/rea.mjs"),
REA_TEST_SYMLINK: fixture.link,
REA_TEST_READLINK_COUNT: fixture.readlinkCount,
REA_TEST_REMOVE_ON_SECOND_READ: fixture.removeOnSecondRead ? "1" : "0",
},
});
expect(result.exitCode, result.stderr || result.stdout).toBe(0);
expect(await readFile(fixture.readlinkCount, "utf8")).toBe("read\nread\n");
expect((await lstat(fixture.link)).isSymbolicLink()).toBe(true);
expect(result.json).toMatchObject({
normalized_result: {
truncated: false,
files_before: expect.arrayContaining([
expect.objectContaining({
path: "root_0:vanishing-link",
type: "symlink",
symlink_target: "target-does-not-need-to-exist",
}),
]),
files_after: expect.arrayContaining([
expect.objectContaining({
path: "root_0:vanishing-link",
type: "symlink",
symlink_target: "target-does-not-need-to-exist",
}),
]),
truncation_details: {
filesystem_after: { enumeration_truncated: false },
},
},
});
},
);
captureCliTest(
"retains sibling evidence and marks a vanished symlink unknown",
async ({ cli }) => {
const fixture = await createScenario(true);
const result = await cli.run({
arguments: ["capture-process", fixture.scenario, "--json"],
cwd: fixture.root,
environment: {
NODE_OPTIONS: `--import=${pathToFileURL(fixture.preload).href}`,
REA_TEST_CLI_ENTRY: resolve("scripts/rea.mjs"),
REA_TEST_SYMLINK: fixture.link,
REA_TEST_READLINK_COUNT: fixture.readlinkCount,
REA_TEST_REMOVE_ON_SECOND_READ: "1",
},
});
expect(await readFile(fixture.readlinkCount, "utf8")).toBe("read\nread\n");
await expect(readFile(fixture.link)).rejects.toMatchObject({
code: "ENOENT",
});
expect(result.exitCode, result.stderr || result.stdout).toBe(0);
expect(result.json).toMatchObject({
normalized_result: {
truncated: true,
files_before: expect.arrayContaining([
expect.objectContaining({
path: "root_0:vanishing-link",
type: "symlink",
}),
expect.objectContaining({ path: "root_0:sibling.txt", type: "file" }),
]),
files_after: expect.arrayContaining([
expect.objectContaining({ path: "root_0:sibling.txt", type: "file" }),
]),
filesystem_effects: expect.arrayContaining([
expect.objectContaining({
path: "root_0:vanishing-link",
status: "unknown",
after: null,
}),
]),
truncation_details: {
filesystem_after: {
enumeration_truncated: true,
enumeration_reasons: expect.arrayContaining(["entry_unavailable"]),
enumeration_omissions: [
{
path: "root_0:vanishing-link",
reason: "entry_unavailable",
system_code: "ENOENT",
},
],
},
},
},
});
},
);
@@ -7,8 +7,8 @@ import { afterEach, expect, it, vi } from "vitest";
const observation = vi.hoisted(() => ({
path: "",
namedReads: 0,
cancelAt: 0,
cancelAfterDescriptorInspection: false,
descriptorInspected: false,
controller: undefined as AbortController | undefined,
reason: undefined as Error | undefined,
handles: [] as FileHandle[],
@@ -30,14 +30,23 @@ vi.mock("node:fs/promises", async (importOriginal) => {
...original,
open: async (...args: Parameters<typeof original.open>) => {
const handle = await original.open(...args);
if (args[0] === observation.path) observation.handles.push(handle);
if (args[0] === observation.path) {
observation.handles.push(handle);
const stat = handle.stat.bind(handle);
vi.spyOn(handle, "stat").mockImplementation(async (options) => {
const stats = await stat(options);
observation.descriptorInspected = true;
return stats;
});
}
return handle;
},
lstat: async (...args: Parameters<typeof original.lstat>) => {
const stats = await original.lstat(...args);
if (
args[0] === observation.path &&
++observation.namedReads === observation.cancelAt
observation.cancelAfterDescriptorInspection &&
observation.descriptorInspected
)
observation.controller?.abort(observation.reason);
return stats;
@@ -54,8 +63,8 @@ import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
afterEach(() => {
observation.path = "";
observation.namedReads = 0;
observation.cancelAt = 0;
observation.cancelAfterDescriptorInspection = false;
observation.descriptorInspected = false;
observation.controller = undefined;
observation.reason = undefined;
observation.handles.length = 0;
@@ -76,9 +85,9 @@ it.each(["hash", "snapshot"] as const)(
observation.path = path;
observation.controller = controller;
observation.reason = reason;
// A root file snapshot first inspects presence and entry metadata, then
// reaches the same two named-file inspections as direct hashing.
observation.cancelAt = operation === "hash" ? 2 : 4;
// The final fallback inspection is the named lookup after the opened
// descriptor was inspected. Admission may perform other named lookups.
observation.cancelAfterDescriptorInspection = true;
const result =
operation === "hash"
? hashFile(path, expected, 1_000, controller.signal)
@@ -110,7 +119,7 @@ it("keeps an unchanged fallback digest and closes its descriptor", async () => {
state: "hashed",
sha256: createHash("sha256").update(bytes).digest("hex"),
});
expect(observation.namedReads).toBe(2);
expect(observation.descriptorInspected).toBe(true);
expect(observation.handles).toHaveLength(1);
await expect(observation.handles[0]?.stat()).rejects.toMatchObject({
code: "EBADF",
@@ -0,0 +1,433 @@
import { mkdir, rm, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
const race = vi.hoisted(() => ({
mode: "" as
| "readlink_missing"
| "lstat_missing"
| "lstat_identity_changed"
| "directory_missing"
| "readlink_eio"
| "readlink_eacces"
| "readlink_regular_replacement"
| "cancel_readlink"
| "cancel_readlink_missing"
| "cancel_directory_close"
| "",
target: "",
followup: false,
opened: false,
closeCompleted: false,
controller: undefined as AbortController | undefined,
reason: undefined as Error | undefined,
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs/promises")>();
return {
...original,
readlink: async (...args: Parameters<typeof original.readlink>) => {
const path = args[0];
if (path === race.target && race.mode === "readlink_missing") {
await original.rm(path);
return original.readlink(...args);
}
if (path === race.target && race.mode === "readlink_eio")
throw Object.assign(new Error("injected unexpected readlink failure"), {
code: "EIO",
});
if (path === race.target && race.mode === "readlink_eacces")
throw Object.assign(new Error("injected denied readlink"), {
code: "EACCES",
});
if (
path === race.target &&
race.mode === "readlink_regular_replacement"
) {
await original.rm(path);
await original.writeFile(path, "replacement regular file");
}
if (path === race.target && race.mode === "cancel_readlink_missing") {
await original.rm(path);
try {
return await original.readlink(...args);
} catch (cause: unknown) {
race.controller?.abort(race.reason);
throw cause;
}
}
const target = await original.readlink(...args);
if (path === race.target) {
if (race.mode === "lstat_missing") {
race.followup = true;
await original.rm(path);
} else if (race.mode === "lstat_identity_changed") {
const replacement = `${path}.replacement`;
await original.symlink("replacement-target", replacement);
await original.rm(path);
await original.rename(replacement, path);
race.followup = true;
} else if (race.mode === "cancel_readlink") {
race.controller?.abort(race.reason);
}
}
return target;
},
lstat: async (...args: Parameters<typeof original.lstat>) => {
const path = args[0];
if (
path === race.target &&
(race.mode === "lstat_missing" ||
race.mode === "lstat_identity_changed") &&
race.followup
) {
race.followup = false;
return original.lstat(...args);
}
if (
path === race.target &&
race.mode === "directory_missing" &&
race.opened
) {
race.mode = "";
race.opened = false;
await original.rm(path, { recursive: true });
return original.lstat(...args);
}
return original.lstat(...args);
},
opendir: async (...args: Parameters<typeof original.opendir>) => {
const directory = await original.opendir(...args);
if (args[0] === race.target && race.mode === "directory_missing")
race.opened = true;
if (args[0] === race.target && race.mode === "cancel_directory_close") {
const iterator = directory[Symbol.asyncIterator]();
let closed = false;
const closeBeforeCancel = async () => {
if (closed) return;
closed = true;
try {
await directory.close();
} catch (cause: unknown) {
if (
!(cause instanceof Error) ||
!("code" in cause) ||
cause.code !== "ERR_DIR_CLOSED"
)
throw cause;
}
race.closeCompleted = true;
race.controller?.abort(race.reason);
};
const wrappedIterator = {
[Symbol.asyncIterator]() {
return this;
},
async next() {
const next = await iterator.next();
if (next.done) await closeBeforeCancel();
return next;
},
async return() {
const result = await iterator.return?.();
await closeBeforeCancel();
return result ?? { done: true, value: undefined };
},
};
return new Proxy(directory, {
get(target, property, receiver) {
if (property === Symbol.asyncIterator) return () => wrappedIterator;
return Reflect.get(target, property, receiver);
},
});
}
// The selected directory's next lstat is snapshotRoots' post-enumeration
// identity check. The deletion itself happens in that lstat wrapper.
return directory;
},
};
});
import { parseProcessScenario } from "../../src/domain/process/processScenario.js";
import { classifyFilesystemEffects } from "../../src/process/capture/ProcessFilesystemEffects.js";
import { snapshotRoots } from "../../src/process/capture/FilesystemSnapshot.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
const resetRace = () => {
race.mode = "";
race.target = "";
race.followup = false;
race.opened = false;
race.closeCompleted = false;
race.controller = undefined;
race.reason = undefined;
};
afterEach(resetRace);
const withTemporaryRoot = async <Value>(
prefix: string,
run: (root: string) => Promise<Value>,
): Promise<Value> => {
const root = await createTestTempDirectory(prefix);
try {
return await run(root);
} finally {
resetRace();
await rm(root, { recursive: true, force: true });
}
};
const entryUnavailable = async (
mode:
| "readlink_missing"
| "lstat_missing"
| "readlink_eacces"
| "readlink_regular_replacement",
systemCode: string,
) => {
await withTemporaryRoot("rea-snapshot-entry-race-", async (root) => {
const sibling = join(root, "a-sibling");
const link = join(root, "z-link");
await writeFile(sibling, "retain this sibling");
await symlink("missing-target", link);
const scenario = parseProcessScenario({
executable: process.execPath,
working_directory: root,
filesystem_observation_paths: [root],
});
const before = await snapshotRoots(scenario);
race.mode = mode;
race.target = link;
const after = await snapshotRoots(scenario);
expect(after.files.map(({ path }) => path)).toContain("root_0:a-sibling");
expect(after.files.map(({ path }) => path)).not.toContain("root_0:z-link");
expect(after.completeRoots).toEqual([]);
expect(after.coverage).toMatchObject({
enumeration_truncated: true,
enumeration_reasons: ["entry_unavailable"],
enumeration_omissions: [
{
path: "root_0:z-link",
reason: "entry_unavailable",
system_code: systemCode,
},
],
});
expect(
classifyFilesystemEffects(before, after).find(
({ path }) => path === "root_0:z-link",
),
).toMatchObject({
status: "unknown",
reason: expect.stringContaining("absence remains unknown"),
});
});
};
it.skipIf(process.platform === "win32").each([
{ name: "readlink ENOENT", mode: "readlink_missing", systemCode: "ENOENT" },
{
name: "follow-up lstat ENOENT",
mode: "lstat_missing",
systemCode: "ENOENT",
},
{ name: "readlink EACCES", mode: "readlink_eacces", systemCode: "EACCES" },
{
name: "regular-file replacement (readlink EINVAL)",
mode: "readlink_regular_replacement",
systemCode: "EINVAL",
},
] as const)(
"retains sibling observations after $name",
async ({ mode, systemCode }) => {
await entryUnavailable(mode, systemCode);
},
);
it.skipIf(process.platform === "win32")(
"records known symlink identity replacement separately from unavailable entries",
async () =>
withTemporaryRoot("rea-snapshot-link-identity-", async (root) => {
const sibling = join(root, "a-sibling");
const link = join(root, "z-link");
await writeFile(sibling, "retain this sibling");
await symlink("original-target", link);
const scenario = parseProcessScenario({
executable: process.execPath,
working_directory: root,
filesystem_observation_paths: [root],
});
const before = await snapshotRoots(scenario);
race.mode = "lstat_identity_changed";
race.target = link;
const after = await snapshotRoots(scenario);
expect(after.files.map(({ path }) => path)).toContain("root_0:a-sibling");
expect(after.files.map(({ path }) => path)).not.toContain(
"root_0:z-link",
);
expect(after.completeRoots).toEqual([]);
expect(after.coverage.enumeration_reasons).toEqual(["identity_changed"]);
expect(after.coverage.enumeration_omissions).toContainEqual({
path: "root_0:z-link",
reason: "identity_changed",
system_code: null,
});
expect(
classifyFilesystemEffects(before, after).find(
({ path }) => path === "root_0:z-link",
)?.status,
).toBe("unknown");
}),
);
it("retains siblings and marks child absence unknown when directory revalidation sees ENOENT", async () =>
withTemporaryRoot("rea-snapshot-directory-race-", async (root) => {
const sibling = join(root, "a-sibling");
const directory = join(root, "z-directory");
await writeFile(sibling, "retain this sibling");
await mkdir(directory);
await writeFile(join(directory, "child"), "child observed before race");
const scenario = parseProcessScenario({
executable: process.execPath,
working_directory: root,
filesystem_observation_paths: [root],
});
const before = await snapshotRoots(scenario);
race.mode = "directory_missing";
race.target = directory;
const after = await snapshotRoots(scenario);
expect(after.files.map(({ path }) => path)).toContain("root_0:a-sibling");
expect(after.files.map(({ path }) => path)).toContain("root_0:z-directory");
expect(after.files.map(({ path }) => path)).not.toContain(
join("root_0:z-directory", "child"),
);
expect(after.completeRoots).toEqual([]);
expect(after.coverage.enumeration_reasons).toEqual(["entry_unavailable"]);
expect(after.coverage.enumeration_omissions).toContainEqual({
path: "root_0:z-directory",
reason: "entry_unavailable",
system_code: "ENOENT",
});
expect(
classifyFilesystemEffects(before, after).find(
({ path }) => path === join("root_0:z-directory", "child"),
),
).toMatchObject({ status: "unknown" });
}));
it.skipIf(process.platform === "win32")(
"propagates cancellation exactly during symlink read",
async () =>
withTemporaryRoot("rea-snapshot-cancel-race-", async (root) => {
const link = join(root, "link");
await symlink("missing-target", link);
const scenario = parseProcessScenario({
executable: process.execPath,
working_directory: root,
filesystem_observation_paths: [root],
});
const controller = new AbortController();
const reason = new Error("cancel during symlink read");
race.mode = "cancel_readlink";
race.target = link;
race.controller = controller;
race.reason = reason;
await expect(snapshotRoots(scenario, controller.signal)).rejects.toBe(
reason,
);
}),
);
it.skipIf(process.platform === "win32")(
"prefers the exact cancellation reason over ENOENT from the pending readlink",
async () =>
withTemporaryRoot("rea-snapshot-cancel-missing-", async (root) => {
const link = join(root, "link");
await symlink("missing-target", link);
const scenario = parseProcessScenario({
executable: process.execPath,
working_directory: root,
filesystem_observation_paths: [root],
});
const controller = new AbortController();
const reason = new Error("cancel while readlink reports ENOENT");
race.mode = "cancel_readlink_missing";
race.target = link;
race.controller = controller;
race.reason = reason;
await expect(snapshotRoots(scenario, controller.signal)).rejects.toBe(
reason,
);
}),
);
it("observes cancellation after the selected directory iterator closes", async () =>
withTemporaryRoot("rea-snapshot-directory-close-cancel-", async (root) => {
const directory = join(root, "selected");
await mkdir(directory);
await writeFile(join(directory, "child"), "observed child");
const scenario = parseProcessScenario({
executable: process.execPath,
working_directory: root,
filesystem_observation_paths: [directory],
});
const controller = new AbortController();
const reason = new Error("cancel after directory iterator close");
race.mode = "cancel_directory_close";
race.target = directory;
race.controller = controller;
race.reason = reason;
await expect(snapshotRoots(scenario, controller.signal)).rejects.toBe(
reason,
);
expect(race.closeCompleted).toBe(true);
}));
it("releases buffered names after a failed directory revalidation before visiting the next root", async () =>
withTemporaryRoot("rea-snapshot-pending-names-", async (parent) => {
const firstRoot = join(parent, "first");
const secondRoot = join(parent, "second");
await mkdir(firstRoot);
await mkdir(secondRoot);
await writeFile(join(firstRoot, "a-child"), "first");
await writeFile(join(firstRoot, "b-child"), "second");
await writeFile(join(secondRoot, "child"), "retain this root");
const scenario = parseProcessScenario({
executable: process.execPath,
working_directory: parent,
filesystem_observation_paths: [firstRoot, secondRoot],
limits: { files: 4 },
});
race.mode = "directory_missing";
race.target = firstRoot;
const after = await snapshotRoots(scenario);
expect(after.files.map(({ path }) => path)).toContain("root_1:child");
expect(after.completeRoots).toEqual(["root_1"]);
expect(after.coverage.enumeration_omissions).toContainEqual({
path: "root_0:.",
reason: "entry_unavailable",
system_code: "ENOENT",
});
}));
it.skipIf(process.platform === "win32")(
"propagates unexpected EIO instead of classifying it as an unavailable entry",
async () =>
withTemporaryRoot("rea-snapshot-eio-control-", async (root) => {
const link = join(root, "link");
await symlink("missing-target", link);
const scenario = parseProcessScenario({
executable: process.execPath,
working_directory: root,
filesystem_observation_paths: [root],
});
race.mode = "readlink_eio";
race.target = link;
await expect(snapshotRoots(scenario)).rejects.toMatchObject({
code: "EIO",
});
}),
);