Skip to content

vfs: fs hook gaps - #65852

Open
pipobscure wants to merge 3 commits into
nodejs:mainfrom
pipobscure:vfs-fs-hook-gaps
Open

vfs: fs hook gaps#65852
pipobscure wants to merge 3 commits into
nodejs:mainfrom
pipobscure:vfs-fs-hook-gaps

Conversation

@pipobscure

@pipobscure pipobscure commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

vfs: close gaps in the fs hooks for mounted paths

Several node:fs entry points behave differently for a mounted path
than for a real one, because of how the call reaches the VFS hooks:

  • fs.watchFile and fs.promises.watch call handler methods that do
    not exist, so they throw a TypeError instead of watching.
  • fs.watch on a path that does not exist returns a polling watcher
    instead of throwing ENOENT, and that watcher keeps the process alive.
  • fs.utimesSync and fs.readdirSync consult the hook before
    validating
    their arguments: numeric-string timestamps are ignored, an object
    timestamp becomes NaN, and an invalid encoding is accepted.
  • fs.futimesSync and fs.fchmodSync on a virtual descriptor are
    no-ops while the path forms of the same operations work.
  • fs.mkdtempSync with a prefix ending in a separator creates the
    directory next to the intended parent, because the prefix is resolved
    as a path before the suffix is appended.
  • fs.mkdirSync({ recursive: true }) returns the provider-relative path
    of the first directory created instead of the mounted path.
  • Disposing an already closed virtual Dir asynchronously rejects with
    ERR_DIR_CLOSED; the real Dir treats disposal as idempotent.

Note: Since these are gaps/defects in existing functionality, I decided to create the failing tests first (first commit) and then add the fix/solution as a second commit. That way whoever wants to review this can first prove out the issue, before applying the solution.

This goes with the VFS work by @mcollina and the bug-fix PRs by @trivikr.

Several `node:fs` entry points behave differently for a mounted path
than for a real one, because of how the call reaches the VFS hooks:

* `fs.watchFile` and `fs.promises.watch` call handler methods that do
  not exist, so they throw a TypeError instead of watching.
* `fs.watch` on a path that does not exist returns a polling watcher
  instead of throwing ENOENT, and that watcher keeps the process alive.
* `fs.utimesSync` and `fs.readdirSync` consult the hook before
  validating
  their arguments: numeric-string timestamps are ignored, an object
  timestamp becomes NaN, and an invalid encoding is accepted.
* `fs.futimesSync` and `fs.fchmodSync` on a virtual descriptor are
  no-ops while the path forms of the same operations work.
* `fs.mkdtempSync` with a prefix ending in a separator creates the
  directory next to the intended parent, because the prefix is resolved
  as a path before the suffix is appended.
* `fs.mkdirSync({ recursive: true })` returns the provider-relative path
  of the first directory created instead of the mounted path.
* Disposing an already closed virtual `Dir` asynchronously rejects with
  ERR_DIR_CLOSED; the real `Dir` treats disposal as idempotent.

This adds a test per gap, stating the real-fs outcome as the
expectation.

Proposed solution: add `watchFile`, `unwatchFile` and `promisesWatch`
handlers backed by the provider's stat watcher and async watcher, and
have `watch` stat the path first; move the hook calls in `utimesSync`
and `readdirSync` after argument validation, and coerce times with
`toUnixTimestamp` in the hook; route `futimes`/`fchmod` to the handle's
entry; strip the trailing separator only after computing the temp
name in `mkdtemp`; map the recursive `mkdir` result back under the mount
point; and make `VirtualDir`'s async dispose a no-op once closed.

Signed-off-by: Philipp Dunkel <pip@pipobscure.com>
Make the `node:fs` entry points that reach a mounted path behave as
they do for a real one:

* Add the `watchFile`, `unwatchFile` and `promisesWatch` handlers,
  backed by the provider's stat watcher and async watcher, and have
  `watch` refuse a path that does not exist with ENOENT instead of
  handing back a watcher that polls forever.
* Convert timestamps and validate arguments before the hook runs in
  `utimes`, `lutimes` and `readdir` (sync, callback and promise forms),
  so a mounted path gets the same ERR_INVALID_ARG_* errors and the same
  seconds-since-epoch numbers as a real one.
* Pass the mode and times through to the `fchmod` and `futimes` hooks
  and route them to the handle's entry, so descriptor operations take
  effect like their path forms; the memory handle validates the way a
  FileHandle would since one calls it directly.
* Treat a `mkdtemp` prefix as text rather than a path when it ends in a
  separator, so the directory is created inside the intended parent.
* Map the first directory a recursive `mkdir` created back under the
  mount point.
* Make disposing an already closed virtual `Dir` a no-op, as on the
  native `Dir`.

The existing file handle test asserted that `chmod()` and `utimes()`
without arguments were no-ops; they now validate and apply, so it
exercises that instead.

Signed-off-by: Philipp Dunkel <pip@pipobscure.com>
@nodejs-github-bot nodejs-github-bot added lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run. labels Sep 6, 2026
@pipobscure
pipobscure marked this pull request as ready for review September 6, 2026 14:23
@trivikr trivikr added the request-ci Add this label to start a Jenkins CI on a PR. label Sep 6, 2026
@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. label Sep 6, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codecov

codecov Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.67391% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.18%. Comparing base (7991140) to head (fc111c7).
⚠️ Report is 6 commits behind head on main.

Files with missing lines Patch % Lines
lib/internal/vfs/setup.js 70.58% 15 Missing ⚠️
lib/internal/vfs/dir.js 50.00% 3 Missing ⚠️
lib/internal/vfs/file_system.js 96.87% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #65852      +/-   ##
==========================================
- Coverage   90.19%   90.18%   -0.01%     
==========================================
  Files         771      771              
  Lines      264622   264717      +95     
  Branches    50223    50272      +49     
==========================================
+ Hits       238663   238734      +71     
- Misses      16965    16978      +13     
- Partials     8994     9005      +11     
Files with missing lines Coverage Δ
lib/fs.js 97.29% <100.00%> (-0.01%) ⬇️
lib/internal/fs/promises.js 91.30% <100.00%> (+0.20%) ⬆️
lib/internal/vfs/file_handle.js 98.95% <100.00%> (+0.06%) ⬆️
lib/internal/vfs/file_system.js 99.61% <96.87%> (-0.08%) ⬇️
lib/internal/vfs/dir.js 97.24% <50.00%> (-2.76%) ⬇️
lib/internal/vfs/setup.js 86.61% <70.58%> (-0.92%) ⬇️

... and 29 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread lib/internal/fs/promises.js Outdated
async function utimes(path, atime, mtime) {
path = getValidatedPath(path);
// Converted before the VFS hook so a mounted path gets the same
// validation and the same seconds-since-epoch numbers as a real one.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can remove this comment

The previous commit added comments that narrate what the adjacent code
does. Its commit message already carries the reasoning, so remove them.

Signed-off-by: Philipp Dunkel <pip@pipobscure.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants