Skip to content

vfs: make the reserved root readable through fs - #66140

Open
pipobscure wants to merge 1 commit into
nodejs:mainfrom
pipobscure:vfs-readable-root
Open

pipobscure wants to merge 1 commit into
nodejs:mainfrom
pipobscure:vfs-readable-root

Conversation

@pipobscure

Copy link
Copy Markdown
Contributor

The reserved root ${os.devNull}/vfs, which holds the mount points of all virtual file systems, could not be read: fs calls on it fell through to the real file system, so nothing could list what was mounted.

While any file system is mounted, serve the root as a read-only directory. It lists every mount point by its layer id, a recursive listing descends into each mounted file system, and paths under it that no mount serves report ENOENT. Creating, removing or changing entries in it fails with EROFS. When nothing is mounted it does not exist, as before.

A mount point cannot be removed or renamed, nor replaced by a rename: rmdir() and rename() fail with EBUSY, and a recursive rm() empties the file system and then fails the same way. Before, rmdir() of an empty mount point reported success without doing anything.

The callback and promise forms of readdir() with withFileTypes now report each Dirent's parentPath as a host path, as readdirSync() did, instead of the provider-relative one, and split recursive names such as dir/file.txt into their directory and base name. A recursive listing joins subdirectories with the host separator rather than /, which mixed separators on Windows. realpath() of a mount point no longer returns it with a trailing separator.

@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 19, 2026
@bakkot

bakkot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

The docs for vfs.mount() still say

The virtual path scheme is subject to change and users should not manually construct them based on assumptions. Instead, obtain them from what vfs.mount() returns or vfs.mountPoint.

Presumably that should be updated?

@pipobscure

pipobscure commented Sep 19, 2026

Copy link
Copy Markdown
Contributor Author

Presumably that should be updated?

The reason I left it because the feature is experimental and we're not constructing a mount-paths, but rather the well-known root of all vfs-mounts. Though that's debatable. I'll go with whatever you say, but am happy to remove it if this rationale seems too flimsy.

The question that brought up for me though is whether we want a require('node:vfs').vfsBase() that returns the base path for vfs, so that I can then use fs.readdir on it. That way the user can reliably get to the base even if we end up changing the base path. (I'd do that as a separate PR if desired)

@codecov

codecov Bot commented Sep 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.78238% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.29%. Comparing base (bca9bbe) to head (4782615).
⚠️ Report is 15 commits behind head on main.

Files with missing lines Patch % Lines
lib/internal/vfs/root.js 89.67% 22 Missing ⚠️
lib/internal/vfs/setup.js 96.55% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #66140      +/-   ##
==========================================
+ Coverage   90.28%   90.29%   +0.01%     
==========================================
  Files         790      791       +1     
  Lines      271982   272314     +332     
  Branches    51915    51999      +84     
==========================================
+ Hits       245546   245882     +336     
+ Misses      16947    16930      -17     
- Partials     9489     9502      +13     
Files with missing lines Coverage Δ
lib/internal/process/pre_execution.js 95.37% <100.00%> (-0.39%) ⬇️
lib/internal/vfs/errors.js 100.00% <100.00%> (ø)
lib/internal/vfs/file_system.js 99.63% <100.00%> (+0.01%) ⬆️
lib/internal/vfs/router.js 98.93% <100.00%> (+1.46%) ⬆️
lib/internal/vfs/setup.js 87.12% <96.55%> (+0.15%) ⬆️
lib/internal/vfs/root.js 89.67% <89.67%> (ø)

... and 35 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.

@bakkot

bakkot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Mostly I just don't like it when docs seem to contradict themselves; I always assume I'm misreading something. (Right now one part of the docs appears to be saying "don't assume things are under devnull" and another says to enumerate devnull.)

Adding vfsBase and then changing the above note to mention that enumerating this directory is an acceptable way to obtain a mount point would resolve my concern. Or we could decide that actually it's fine to assume things will be under devnull and document that. Either way.

@pipobscure

Copy link
Copy Markdown
Contributor Author

Heads-up for reviewers: this branch now also reserves layer id 0 for the file system --vfs-load mounts, which is beyond what the PR title and description say.

What changed:

  • kLoadLayerId = 0 in lib/internal/vfs/file_system.js, and every other mount is numbered from 1 (nextLayerId = kLoadLayerId + 1).
  • A new internal kLoadLayer option marks the one file system a --vfs-load mounts, so it takes layer 0 whatever else the thread mounts first, including a --vfs-mount ahead of it or a -r preload that mounts a file system of its own.
  • finishVfsMounts() in lib/internal/process/pre_execution.js passes that option for the --vfs-load source. A worker still mounts every source ([vfs_load_set] is forced off there) but runs its own entry rather than the mount's.

Why it matters: the mount point of a --vfs-load source is now the same in every thread, so a path into it stays valid in a worker. That includes a worker created with an explicit execArgv, which does not inherit the parent's options and so only gets its own --vfs-load. test/parallel/test-vfs-mount-load.js covers all three orderings.

One thing worth deciding: the reservation is currently only stated in code comments. doc/api/vfs.md shows it implicitly, in an example where the first mount is '1'. If layer 0 is meant to be a stable guarantee that programs can rely on, it probably belongs in the documentation; if it is an implementation detail, the example may be enough.

@pipobscure

Copy link
Copy Markdown
Contributor Author

@mcollina, this is now the basic PR split out of the earlier one, followed by either #66119 or #66162 depending on which direction we want to take.

The reserved root `${os.devNull}/vfs`, which holds the mount points of
all virtual file systems, could not be read: fs calls on it fell through
to the real file system, so nothing could list what was mounted.

While any file system is mounted, serve the root as a read-only
directory. It lists every mount point by its layer id, a recursive
listing descends into each mounted file system, and paths under it that
no mount serves report ENOENT. Creating, removing or changing entries in
it fails with EROFS. When nothing is mounted it does not exist, as
before.

A mount point cannot be removed or renamed, nor replaced by a rename:
rmdir() and rename() fail with EBUSY, and a recursive rm() empties the
file system and then fails the same way. Before, rmdir() of an empty
mount point reported success without doing anything.

Reserve layer 0 for the file system --vfs-load mounts, and number the
others from 1. That source is then at the same reserved mount point in
every thread, whatever else a thread mounts and wherever --vfs-load is
written among the other mounts, so a path into it stays valid in a
worker - including a worker created with its own execArgv, which
inherits none of the parent's options and has to be given --vfs-load
again. A worker still does not run that entry point, but it now has to
recognize which source it belongs to in order to mount it at that layer.

The callback and promise forms of readdir() with `withFileTypes` now
report each Dirent's parentPath as a host path, as readdirSync() did,
instead of the provider-relative one, and split recursive names such as
`dir/file.txt` into their directory and base name. A recursive listing
joins subdirectories with the host separator rather than `/`, which
mixed separators on Windows. realpath() of a mount point no longer
returns it with a trailing separator.

Add vfs.vfsBase(), which returns that directory, so a program can list
what is mounted without spelling out `path.join(os.devNull, 'vfs')`. The
note under vfs.mount() said the path scheme must not be relied on, which
read as a contradiction of the root being listable; it now says where a
mount point comes from, and that only the name within the root is
assigned at runtime.

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.

4 participants