Skip to content

vfs: fs hook gaps - #65852

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

vfs: fs hook gaps#65852
pipobscure wants to merge 2 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

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.

4 participants