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:
1 parent
030c477328
commit
e67c3642c8
3 files changed
+85
-7
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user