[GH-ISSUE #3238] Process capture fails when an observed file parent becomes a regular file #1162

Closed
opened 2026-10-15 18:31:39 +00:00 by bsakaguchi · 2 comments
Owner

Originally created by @xu91102 on GitHub (Oct 14, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/3238

Selecting a file for filesystem observation can make an otherwise successful process capture fail when the process replaces the file's parent directory with a regular file. The selected descendant is absent, but the final snapshot throws ENOTDIR instead of retaining the ordinary deleted-file effect.

Reproduced with the built public CLI on main 500e32399a3bc827e17fb4adaa97db3bc0df29b5, Node 24.18.0, macOS arm64. Only a test-owned temporary directory and Node program were used.

Create the fixture and scenario with the pinned Node:

const fs = require('node:fs');
const { join } = require('node:path');
const { tmpdir } = require('node:os');
const root = fs.mkdtempSync(join(tmpdir(), 'rea-observed-parent-'));
fs.mkdirSync(join(root, 'reports'));
fs.writeFileSync(join(root, 'reports/final.json'), '{"result":42}\n');
const scenario = {
  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')],
  events: [], timeout_ms: 5000, idle_timeout_ms: 5000, settle_ms: 100,
};
const path = join(root, 'scenario.json');
fs.writeFileSync(path, JSON.stringify(scenario));
console.log(path);

Run node scripts/rea.mjs capture-process <printed-scenario-path> --json.

Actual: CLI exit 1 with process_capture_failed / execution_failure; lstat on reports/final.json fails with ENOTDIR. The error contains a partial observation, including terminal output and the child's successful exit. It does not produce completed Capture Evidence or the expected final filesystem effect. The saved original file is still readable at saved-reports/final.json.

Expected: complete the capture with child exit 0 and the selected file reported as deleted, just as when the parent is only renamed. The descendant cannot exist when an intermediate component is a regular file; permission and other unresolved lookup failures must still retain their distinct behavior.

Controls on the same baseline: leaving the file unchanged succeeds; renaming the parent without replacing it succeeds and reports deletion (ENOENT); observing the parent itself when it becomes a regular file succeeds and reports its type change plus the child's deletion. The failing file-selection case and renamed control were independently repeated through the public CLI.

The producing boundary is src/process/capture/FilesystemSnapshot.ts's lstatIfPresent, which recognizes ENOENT only. The existing lifecycle suite explicitly preserves an initial obstructed-path ENOTDIR as an I/O failure. That control should stay intact: the correction is for a root already observed in this capture becoming absent in a subsequent snapshot, and should carry that prior admission evidence. Hashing already retains a separate unavailable-file outcome for ENOTDIR; the problem is root snapshot admission before that stage. Please keep hashing identity, per-file omissions, cancellation, directory enumeration failures and cleanup semantics unchanged.

This is within the documented selected-filesystem capture workflow in docs/process-capture.md. Current targeted searches found no matching open fix. #2545 concerns unreadable-directory enumeration; #956 added hashing identity/cancellation checks; neither covers this absent descendant lookup.

Originally created by @xu91102 on GitHub (Oct 14, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3238 Selecting a file for filesystem observation can make an otherwise successful process capture fail when the process replaces the file's parent directory with a regular file. The selected descendant is absent, but the final snapshot throws `ENOTDIR` instead of retaining the ordinary deleted-file effect. Reproduced with the built public CLI on main `500e32399a3bc827e17fb4adaa97db3bc0df29b5`, Node 24.18.0, macOS arm64. Only a test-owned temporary directory and Node program were used. Create the fixture and scenario with the pinned Node: ```js const fs = require('node:fs'); const { join } = require('node:path'); const { tmpdir } = require('node:os'); const root = fs.mkdtempSync(join(tmpdir(), 'rea-observed-parent-')); fs.mkdirSync(join(root, 'reports')); fs.writeFileSync(join(root, 'reports/final.json'), '{"result":42}\n'); const scenario = { 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')], events: [], timeout_ms: 5000, idle_timeout_ms: 5000, settle_ms: 100, }; const path = join(root, 'scenario.json'); fs.writeFileSync(path, JSON.stringify(scenario)); console.log(path); ``` Run `node scripts/rea.mjs capture-process <printed-scenario-path> --json`. Actual: CLI exit 1 with `process_capture_failed` / `execution_failure`; `lstat` on `reports/final.json` fails with `ENOTDIR`. The error contains a partial observation, including terminal output and the child's successful exit. It does not produce completed Capture Evidence or the expected final filesystem effect. The saved original file is still readable at `saved-reports/final.json`. Expected: complete the capture with child exit 0 and the selected file reported as deleted, just as when the parent is only renamed. The descendant cannot exist when an intermediate component is a regular file; permission and other unresolved lookup failures must still retain their distinct behavior. Controls on the same baseline: leaving the file unchanged succeeds; renaming the parent without replacing it succeeds and reports deletion (`ENOENT`); observing the parent itself when it becomes a regular file succeeds and reports its type change plus the child's deletion. The failing file-selection case and renamed control were independently repeated through the public CLI. The producing boundary is `src/process/capture/FilesystemSnapshot.ts`'s `lstatIfPresent`, which recognizes `ENOENT` only. The existing lifecycle suite explicitly preserves an initial obstructed-path `ENOTDIR` as an I/O failure. That control should stay intact: the correction is for a root already observed in this capture becoming absent in a subsequent snapshot, and should carry that prior admission evidence. Hashing already retains a separate unavailable-file outcome for `ENOTDIR`; the problem is root snapshot admission before that stage. Please keep hashing identity, per-file omissions, cancellation, directory enumeration failures and cleanup semantics unchanged. This is within the documented selected-filesystem capture workflow in `docs/process-capture.md`. Current targeted searches found no matching open fix. #2545 concerns unreadable-directory enumeration; #956 added hashing identity/cancellation checks; neither covers this absent descendant lookup.
bsakaguchi 2026-10-15 18:31:39 +00:00
  • closed this issue
  • added the
    bug
    label
Author
Owner

@CRYPTONIKAV commented on GitHub (Oct 14, 2026):

Reproduced this with the built CLI and worked out a minimal fix. PR creation from my fork is currently repository-blocked (REST 404, same as another contributor reported on #3223), so leaving the patch here:

Root cause: the final filesystem snapshot treats an unreachable selected path as a failure for every errno. A path that existed at capture start and now resolves ENOTDIR (parent became a regular file) is gone, exactly like a rename (ENOENT) — but only ENOENT mapped to absent.

Fix (3 files, branch fix/3238-enotdir-deleted for cherry-pick):

  • src/process/capture/FilesystemSnapshot.ts: lstatIfPresent/snapshotRoots take absent codes (default ["ENOENT"], behavior unchanged everywhere else).
  • src/process/capture/ProcessHarness.ts: only the final snapshot passes ["ENOENT", "ENOTDIR"]. The initial snapshot still fails ENOTDIR (a never-resolved path is a caller error), and EACCES etc. still throw.
  • src/process/capture/FilesystemCoverage.test.ts: regression test pinning both sides.

Verification: issue repro now yields root_0:. with status: "deleted"; snapshot suites 21 passed, harness+lifecycle 55 passed; tsc/oxfmt/oxlint clean.

Honest limit: mid-traversal revalidation lstats can still throw on races (pre-existing for ENOENT too).

<!-- gh-comment-id:6163265205 --> @CRYPTONIKAV commented on GitHub (Oct 14, 2026): Reproduced this with the built CLI and worked out a minimal fix. PR creation from my fork is currently repository-blocked (REST 404, same as another contributor reported on #3223), so leaving the patch here: **Root cause:** the final filesystem snapshot treats an unreachable selected path as a failure for every errno. A path that existed at capture start and now resolves `ENOTDIR` (parent became a regular file) is gone, exactly like a rename (`ENOENT`) — but only `ENOENT` mapped to absent. **Fix** (3 files, branch [`fix/3238-enotdir-deleted`](https://github.com/CRYPTONIKAV/rea/tree/fix/3238-enotdir-deleted) for cherry-pick): - `src/process/capture/FilesystemSnapshot.ts`: `lstatIfPresent`/`snapshotRoots` take absent codes (default `["ENOENT"]`, behavior unchanged everywhere else). - `src/process/capture/ProcessHarness.ts`: only the **final** snapshot passes `["ENOENT", "ENOTDIR"]`. The **initial** snapshot still fails `ENOTDIR` (a never-resolved path is a caller error), and `EACCES` etc. still throw. - `src/process/capture/FilesystemCoverage.test.ts`: regression test pinning both sides. **Verification:** issue repro now yields `root_0:.` with `status: "deleted"`; snapshot suites 21 passed, harness+lifecycle 55 passed; `tsc`/`oxfmt`/`oxlint` clean. **Honest limit:** mid-traversal revalidation `lstat`s can still throw on races (pre-existing for `ENOENT` too).
Author
Owner

@morluto commented on GitHub (Oct 14, 2026):

Resolved on main by e67c3642c.

A previously observed file whose parent becomes a regular file is retained as a deleted observation; initial ENOTDIR obstruction still fails admission.

Rechecked on main b8510ee00 in the current focused run: 20 test files passed, 225 tests passed and one platform-dependent test skipped. Relevant regression coverage: tests/boundary/process/processCaptureLifecycle.test.ts.

<!-- gh-comment-id:6174985312 --> @morluto commented on GitHub (Oct 14, 2026): Resolved on main by e67c3642c. A previously observed file whose parent becomes a regular file is retained as a deleted observation; initial ENOTDIR obstruction still fails admission. Rechecked on main b8510ee00 in the current focused run: 20 test files passed, 225 tests passed and one platform-dependent test skipped. Relevant regression coverage: tests/boundary/process/processCaptureLifecycle.test.ts.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#1162
No description provided.