fix(process): retain deletions after observed roots become obstructed

Carry initial root observations into final capture. Treat ENOTDIR as absence only for a previously observed root, preserving initial obstruction and cancellation failures.

Refs: #3238, #3263

Co-authored-by: hhang <169027046+xu91102@users.noreply.github.com>
This commit is contained in:
morlutoandhhang committed 2026-10-14 17:49:17 +08:00
1 parent 030c477328
commit e67c3642c8
3 files changed
+85 -7

No files matched your search

+30 -5
View File
@@ -47,12 +47,26 @@ const fileUnavailableSystemCode = (cause: unknown): string | undefined => {
: undefined;
};
const lstatIfPresent = async (path: string): Promise<Stats | undefined> => {
const lstatIfPresent = async (
path: string,
previouslyObservedRoot = false,
signal?: AbortSignal,
): Promise<Stats | undefined> => {
signal?.throwIfAborted();
try {
return await lstat(path);
const stats = await lstat(path);
signal?.throwIfAborted();
return stats;
} catch (cause: unknown) {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
if (
cause instanceof Error &&
"code" in cause &&
(cause.code === "ENOENT" ||
(previouslyObservedRoot && cause.code === "ENOTDIR"))
) {
signal?.throwIfAborted();
return undefined;
}
throw cause;
}
};
@@ -169,7 +183,11 @@ export const hashFile = async (
export const snapshotRoots = async (
scenario: ProcessScenario,
signal?: AbortSignal,
previous?: ProcessFilesystemSnapshot,
): Promise<ProcessFilesystemSnapshot> => {
// A prior root entry establishes same-run admission. Preserve initial
// obstruction errors; a later non-directory ancestor makes it absent.
const previousPaths = new Set(previous?.files.map(({ path }) => path));
const entries: FileState[] = [];
const completeRoots: string[] = [];
let remainingBytes = scenario.limits.file_bytes;
@@ -198,7 +216,11 @@ export const snapshotRoots = async (
enumerationReasons.add("depth_limit");
return false;
}
const stats = await lstatIfPresent(path);
const stats = await lstatIfPresent(
path,
previousPaths.has(`${rootAlias}:.`),
signal,
);
if (stats === undefined) return true;
const relativePath = relative(root, path) || ".";
if (stats.isSymbolicLink()) {
@@ -326,7 +348,10 @@ export const snapshotRoots = async (
};
for (const [index, root] of scenario.filesystem_observation_paths.entries()) {
const alias = `root_${String(index)}`;
if ((await lstatIfPresent(root)) === undefined) {
if (
(await lstatIfPresent(root, previousPaths.has(`${alias}:.`), signal)) ===
undefined
) {
completeRoots.push(alias);
continue;
}
+5 -1
View File
@@ -508,7 +508,11 @@ const completeCapture = async (options: {
assertNotCancelled(options.signal);
let after: Awaited<ReturnType<typeof snapshotRoots>>;
try {
after = await options.captureSnapshot(scenario, options.signal);
after = await options.captureSnapshot(
scenario,
options.signal,
options.before,
);
} catch (cause: unknown) {
options.observationBuffer.filesystem_snapshots = {
...options.observationBuffer.filesystem_snapshots,
@@ -1,6 +1,13 @@
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { access, readFile, rm, symlink, writeFile } from "node:fs/promises";
import {
access,
mkdir,
readFile,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
@@ -123,6 +130,48 @@ const terminateFixtureProcess = async (pid: number): Promise<void> => {
await waitForProcessExit(pid);
};
itWithCaptureCapability(
"retains a deleted observed file when its parent becomes a regular file",
async () => {
const root = await createTestTempDirectory("rea-observed-parent-type-");
const payload = '{"result":42}\n';
await mkdir(join(root, "reports"));
await writeFile(join(root, "reports", "final.json"), payload);
const result = await captureProcessScenario(
parseProcessScenario({
executable: process.execPath,
arguments: [
"-e",
`const fs = require('node:fs');
fs.renameSync('reports', 'saved-reports');
fs.writeFileSync('reports', 'regular file'); console.log('finished');`,
],
working_directory: root,
filesystem_observation_paths: [join(root, "reports", "final.json")],
timeout_ms: 5_000,
idle_timeout_ms: 5_000,
settle_ms: 100,
}),
);
if (!result.ok) throw result.error;
expect(result.value.exit).toMatchObject({ code: 0, reason: "exited" });
expect(result.value.filesystem_effects).toContainEqual(
expect.objectContaining({
path: "root_0:.",
status: "deleted",
before: expect.objectContaining({
type: "file",
sha256: createHash("sha256").update(payload).digest("hex"),
}),
after: null,
}),
);
expect(
await readFile(join(root, "saved-reports", "final.json"), "utf8"),
).toBe(payload);
},
);
itWithCaptureCapability(
"keeps the process deadline bounded when the wall clock moves backward",
async () => {