Commit 28994575bcd for nodejs
commit 28994575bcd7a7f10d1c9ca1046c8391571f9448
Author: Philipp Dunkel <pipobscure@users.noreply.github.com>
Date: Wed Sep 23 16:45:25 2026 +0200
vfs: drop --vfs-mount, pin the load mount point
--vfs-mount mounted a source without running it, and shared one ordered
list of sources with --vfs-load, so neither option could say which entry
it had contributed: the entry point was recovered from the position of
--vfs-load among the mounts, in a list NODE_OPTIONS could prepend to.
Nothing needs more than one mount from the command line: a program that
wants more can mount them itself through node:vfs, where it also gets
the instance.
Remove --vfs-mount, leaving --vfs-load with the single source it mounts
and runs, and reserve layer 0 for that source, numbering the file
systems a program mounts itself from 1. The source is then at the same
mount point in every thread, whatever else that thread mounts -
including a thread where a --require preload mounted a file system of
its own first - so a path into it stays valid in a worker.
A worker still does not run that entry point: it inherits the source but
not the decision to load from it. A worker created with its own execArgv
inherits neither, so the documentation now says that such a worker must
be given --experimental-vfs and --vfs-load again to run a script from
the mount, and that --experimental-vfs is also what makes node:vfs
available to the worker's own code.
ERR_VFS_INVALID_TARGET now names --vfs-load as the source's origin, and
the startup test moves to test-vfs-load.js, with the cases that covered
mounting without loading removed and cases for the reserved mount point
added.
Signed-off-by: Philipp Dunkel <pip@pipobscure.com>
PR-URL: https://github.com/nodejs/node/pull/66162
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
diff --git a/doc/api/cli.md b/doc/api/cli.md
index cde00859c92..f3cc2ee7e1a 100644
--- a/doc/api/cli.md
+++ b/doc/api/cli.md
@@ -3795,56 +3795,18 @@ added: v26.10.0
Requires [`--experimental-vfs`][]. May be given at most once.
-Mounts `source` exactly as [`--vfs-mount`][] does, and additionally runs the
-entry point and all subsequent `require()`/`import` resolution against that
-mount rather than the real file system. The entry point is taken from the mount
-the same way `node <directory>` takes one: the mount's own `package.json`
-`"main"`, or `index.js`. Any positional command-line argument is the program's
-own (available from `process.argv[2]` onward), never an entry-point override.
+Mounts `source` as a virtual file system ([`node:vfs`][]), and runs the entry
+point and all subsequent `require()`/`import` resolution against that mount
+rather than the real file system. The mount is placed at a reserved mount point
+assigned by Node.js, so it never shadows real paths and no target can be
+chosen. The entry point is taken from the mount the same way `node <directory>`
+takes one: the mount's own `package.json` `"main"`, or `index.js`. Any
+positional command-line argument is the program's own (available from
+`process.argv[2]` onward), never an entry-point override.
`process.argv[1]` reports `source` rather than the reserved mount point, since
the mount point is an opaque implementation detail.
-Mounting the same source twice mounts it twice, at two separate mount points.
-The entry point then comes from the mount `--vfs-load` itself contributed, not
-from an earlier `--vfs-mount` of the same source.
-
-In worker threads `--vfs-load` mounts but does not load: a worker inherits the
-same mounts, in the same order, and runs its own entry point.
-
-`--vfs-load` is not permitted in [`NODE_OPTIONS`][]: which entry point runs is
-the command line's decision, and the environment must not be able to redirect
-it.
-
-```console
-$ node --experimental-vfs --vfs-load=app.zip
-$ node --experimental-vfs --vfs-mount=lib.zip --vfs-load=app.zip
-```
-
-### `--vfs-mount=source`
-
-<!-- YAML
-added: v26.10.0
--->
-
-* `source` {string} A directory or an archive file to mount.
-
-Requires [`--experimental-vfs`][]. May be repeated to mount several sources.
-
-Mounts `source` as a virtual file system ([`node:vfs`][]). Each mount is placed
-at a reserved mount point assigned by Node.js, so mounts never shadow real
-paths and no target can be chosen. Mounting alone does not change the entry
-point; use [`--vfs-load`][] for the source to run from.
-
-`--vfs-mount` and [`--vfs-load`][] mount in the order they are written, so
-
-```console
-$ node --experimental-vfs --vfs-mount=a --vfs-load=b --vfs-mount=c
-```
-
-mounts `a`, `b` and `c` in that order and runs `b`. Mounts contributed by
-[`NODE_OPTIONS`][] are mounted before the command line's.
-
The provider backing a source is chosen from the source itself rather than from
its file name:
@@ -3857,6 +3819,29 @@ preloaded with [`--require`][] or [`--import`][]) are consulted first, in
reverse registration order, and may claim directories as well as files. If no
provider claims the source, Node.js exits with an error.
+In worker threads `--vfs-load` mounts but does not load: a worker inherits the
+mount and runs its own entry point, which may itself live in the mount.
+
+The source is mounted at the same reserved mount point in every thread that
+mounts it, whatever else that thread mounts, so a path into the mount means the
+same thing in all of them.
+
+A worker created with its own `execArgv` inherits none of the parent's options,
+and so does not mount the source at all. To run a script from the mount, such a
+worker must be given the same options again, `--experimental-vfs` and
+`--vfs-load`; without them, that thread has no mount for the script to come
+from, and the worker fails to load it. `--experimental-vfs` is also what makes
+[`node:vfs`][] available to the worker's own code. A worker whose script comes
+from anywhere else, such as the real file system, needs nothing added.
+
+`--vfs-load` is not permitted in [`NODE_OPTIONS`][]: which entry point runs is
+the command line's decision, and the environment must not be able to redirect
+it.
+
+```console
+$ node --experimental-vfs --vfs-load=app.zip
+```
+
### `--watch`
<!-- YAML
@@ -4299,7 +4284,6 @@ one is included in the list below.
* `--use-openssl-ca`
* `--use-system-ca`
* `--v8-pool-size`
-* `--vfs-mount`
* `--watch-kill-signal`
* `--watch-path`
* `--watch-preserve-output`
@@ -4825,8 +4809,6 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[`--require`]: #-r---require-module
[`--use-env-proxy`]: #--use-env-proxy
[`--use-system-ca`]: #--use-system-ca
-[`--vfs-load`]: #--vfs-loadsource
-[`--vfs-mount`]: #--vfs-mountsource
[`AsyncLocalStorage`]: async_context.md#class-asynclocalstorage
[`Buffer`]: buffer.md#class-buffer
[`CRYPTO_secure_malloc_init`]: https://www.openssl.org/docs/man3.0/man3/CRYPTO_secure_malloc_init.html
diff --git a/doc/api/errors.md b/doc/api/errors.md
index f02a2950318..11292b60b5d 100644
--- a/doc/api/errors.md
+++ b/doc/api/errors.md
@@ -3569,7 +3569,7 @@ entry types are found.
### `ERR_VFS_INVALID_TARGET`
-A `--vfs-mount` source does not exist, is neither a regular file nor a
+A `--vfs-load` source does not exist, is neither a regular file nor a
directory, or is a source no provider claims.
<a id="ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING"></a>
diff --git a/doc/api/vfs.md b/doc/api/vfs.md
index 6f6fc53a49e..edbb1c306df 100644
--- a/doc/api/vfs.md
+++ b/doc/api/vfs.md
@@ -106,7 +106,7 @@ added: v26.10.0
* `create` {Function} Called with the resolved path and its [`fs.Stats`][].
Returns the {VirtualProvider} backing the source.
-Registers a provider that [`--vfs-mount`][] can select for a source it
+Registers a provider that [`--vfs-load`][] can select for a source it
recognizes, so a file format Node.js has no built-in provider for can still be
mounted.
@@ -118,7 +118,7 @@ source, the built-in providers handle it: a directory with
[`RealFSProvider`][], and a file whose bytes are a ZIP archive with
[`ZipProvider`][].
-Providers must be registered before the mounts are created. Register from a
+Providers must be registered before the source is mounted. Register from a
module preloaded with [`--require`][] or [`--import`][]:
```cjs
@@ -702,7 +702,7 @@ fields use synthetic but stable values:
[Single Executable Application]: single-executable-applications.md
[`--import`]: cli.md#--importmodule
[`--require`]: cli.md#-r---require-module
-[`--vfs-mount`]: cli.md#--vfs-mountsource
+[`--vfs-load`]: cli.md#--vfs-loadsource
[`MemoryProvider`]: #class-memoryprovider
[`RealFSProvider`]: #class-realfsprovider
[`VirtualFileSystem`]: #class-virtualfilesystem
diff --git a/doc/node.1 b/doc/node.1
index 1b873d1ecca..1f97de57033 100644
--- a/doc/node.1
+++ b/doc/node.1
@@ -1888,43 +1888,16 @@ Print node's version.
\fBsource\fR \fB{string}\fR A directory or an archive file to mount and run.
.El
Requires \fB--experimental-vfs\fR. May be given at most once.
-Mounts \fBsource\fR exactly as \fB--vfs-mount\fR does, and additionally runs the
-entry point and all subsequent \fBrequire()\fR/\fBimport\fR resolution against that
-mount rather than the real file system. The entry point is taken from the mount
-the same way \fBnode <directory>\fR takes one: the mount's own \fBpackage.json\fR
-\fB"main"\fR, or \fBindex.js\fR. Any positional command-line argument is the program's
-own (available from \fBprocess.argv[2]\fR onward), never an entry-point override.
+Mounts \fBsource\fR as a virtual file system (\fBnode:vfs\fR), and runs the entry
+point and all subsequent \fBrequire()\fR/\fBimport\fR resolution against that mount
+rather than the real file system. The mount is placed at a reserved mount point
+assigned by Node.js, so it never shadows real paths and no target can be
+chosen. The entry point is taken from the mount the same way \fBnode <directory>\fR
+takes one: the mount's own \fBpackage.json\fR \fB"main"\fR, or \fBindex.js\fR. Any
+positional command-line argument is the program's own (available from
+\fBprocess.argv[2]\fR onward), never an entry-point override.
\fBprocess.argv[1]\fR reports \fBsource\fR rather than the reserved mount point, since
the mount point is an opaque implementation detail.
-Mounting the same source twice mounts it twice, at two separate mount points.
-The entry point then comes from the mount \fB--vfs-load\fR itself contributed, not
-from an earlier \fB--vfs-mount\fR of the same source.
-In worker threads \fB--vfs-load\fR mounts but does not load: a worker inherits the
-same mounts, in the same order, and runs its own entry point.
-\fB--vfs-load\fR is not permitted in \fBNODE_OPTIONS\fR: which entry point runs is
-the command line's decision, and the environment must not be able to redirect
-it.
-.Bd -literal
-$ node --experimental-vfs --vfs-load=app.zip
-$ node --experimental-vfs --vfs-mount=lib.zip --vfs-load=app.zip
-.Ed
-.
-.It Fl -vfs-mount Ns = Ns Ar source
-.Bl -bullet
-.It
-\fBsource\fR \fB{string}\fR A directory or an archive file to mount.
-.El
-Requires \fB--experimental-vfs\fR. May be repeated to mount several sources.
-Mounts \fBsource\fR as a virtual file system (\fBnode:vfs\fR). Each mount is placed
-at a reserved mount point assigned by Node.js, so mounts never shadow real
-paths and no target can be chosen. Mounting alone does not change the entry
-point; use \fB--vfs-load\fR for the source to run from.
-\fB--vfs-mount\fR and \fB--vfs-load\fR mount in the order they are written, so
-.Bd -literal
-$ node --experimental-vfs --vfs-mount=a --vfs-load=b --vfs-mount=c
-.Ed
-mounts \fBa\fR, \fBb\fR and \fBc\fR in that order and runs \fBb\fR. Mounts contributed by
-\fBNODE_OPTIONS\fR are mounted before the command line's.
The provider backing a source is chosen from the source itself rather than from
its file name:
.Bl -bullet
@@ -1938,6 +1911,24 @@ Providers registered with \fBvfs.registerProvider()\fR (typically from a module
preloaded with \fB--require\fR or \fB--import\fR) are consulted first, in
reverse registration order, and may claim directories as well as files. If no
provider claims the source, Node.js exits with an error.
+In worker threads \fB--vfs-load\fR mounts but does not load: a worker inherits the
+mount and runs its own entry point, which may itself live in the mount.
+The source is mounted at the same reserved mount point in every thread that
+mounts it, whatever else that thread mounts, so a path into the mount means the
+same thing in all of them.
+A worker created with its own \fBexecArgv\fR inherits none of the parent's options,
+and so does not mount the source at all. To run a script from the mount, such a
+worker must be given the same options again, \fB--experimental-vfs\fR and
+\fB--vfs-load\fR; without them, that thread has no mount for the script to come
+from, and the worker fails to load it. \fB--experimental-vfs\fR is also what makes
+\fBnode:vfs\fR available to the worker's own code. A worker whose script comes
+from anywhere else, such as the real file system, needs nothing added.
+\fB--vfs-load\fR is not permitted in \fBNODE_OPTIONS\fR: which entry point runs is
+the command line's decision, and the environment must not be able to redirect
+it.
+.Bd -literal
+$ node --experimental-vfs --vfs-load=app.zip
+.Ed
.
.It Fl -watch
Starts Node.js in watch mode.
@@ -2430,8 +2421,6 @@ one is included in the list below.
.It
\fB--v8-pool-size\fR
.It
-\fB--vfs-mount\fR
-.It
\fB--watch-kill-signal\fR
.It
\fB--watch-path\fR
diff --git a/lib/internal/errors.js b/lib/internal/errors.js
index 27c8202c04e..f8383a3511a 100644
--- a/lib/internal/errors.js
+++ b/lib/internal/errors.js
@@ -1962,7 +1962,7 @@ E('ERR_USE_AFTER_CLOSE', '%s was closed', Error);
E('ERR_VALID_PERFORMANCE_ENTRY_TYPE',
'At least one valid performance entry type is required', Error);
E('ERR_VFS_INVALID_TARGET',
- '%s is not a valid --vfs-mount source: must be an existing file or directory', Error);
+ '%s is not a valid --vfs-load source: must be an existing file or directory', Error);
E('ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING',
'A dynamic import callback was not specified.', TypeError);
E('ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING_FLAG',
diff --git a/lib/internal/main/worker_thread.js b/lib/internal/main/worker_thread.js
index 5c6443661c4..e2b30de754a 100644
--- a/lib/internal/main/worker_thread.js
+++ b/lib/internal/main/worker_thread.js
@@ -145,8 +145,8 @@ port.on('message', (message) => {
// initializeAsyncLoaderHooksOnLoaderHookWorker() which needs to run preloads
// after the asynchronous loader hooks are registered.
initializeModuleLoaders({ shouldSpawnLoaderHookWorker: true, shouldPreloadModules: true });
- // Re-mount inherited --vfs-mount sources so their reserved paths (which a
- // worker filename may point into) resolve in this thread too. With
+ // Re-mount the inherited --vfs-load source so its reserved path (which a
+ // worker filename may point into) resolves in this thread too. With
// --import, mounting is deferred to after that loop in run_main, matching
// the main thread; finishVfsMounts() is idempotent so it runs once.
if (getOptionValue('--import').length === 0) {
diff --git a/lib/internal/process/pre_execution.js b/lib/internal/process/pre_execution.js
index eb6a056cc05..74f4480565e 100644
--- a/lib/internal/process/pre_execution.js
+++ b/lib/internal/process/pre_execution.js
@@ -14,8 +14,6 @@ const {
ObjectDefineProperty,
ObjectFreeze,
String,
- StringPrototypeIndexOf,
- StringPrototypeSlice,
globalThis,
} = primordials;
@@ -216,91 +214,58 @@ function setupVmModules() {
let vfsMounted = false;
let vfsLoadRoot;
-// --vfs-mount and --vfs-load append to one list, so `mounts` is already in the
-// order the command line gave, and the entry point comes from whichever of them
-// --vfs-load contributed. The parser stores plain strings and cannot record
-// which flag produced an entry, so its position is recovered from execArgv -
-// the command line's own node options, in order. NODE_OPTIONS may add mounts
-// but not a --vfs-load, so anything it contributed sits ahead of these.
-// Returns -1 when no --vfs-load was given.
-function getVfsLoadIndex(mountCount) {
- if (!getOptionValue('[vfs_load_set]')) return -1;
-
- const execArgv = process.execArgv;
- let seen = 0;
- let found = -1;
- for (let i = 0; i < execArgv.length; i++) {
- const arg = execArgv[i];
- let name = arg;
- const eq = StringPrototypeIndexOf(arg, '=');
- let spaced = false;
- if (eq !== -1) {
- name = StringPrototypeSlice(arg, 0, eq);
- } else {
- // `--vfs-mount value`: the value is the next argument, so skip it rather
- // than counting it as a flag of its own.
- spaced = true;
- }
- if (name !== '--vfs-mount' && name !== '--vfs-load') continue;
- if (name === '--vfs-load') found = seen;
- seen++;
- if (spaced) i++;
- }
- if (found === -1) return -1;
- // Mounts from NODE_OPTIONS are parsed first and so precede the command
- // line's; `seen` counts only the latter.
- return mountCount - seen + found;
-}
-
-// Mounts every --vfs-mount source. Called from prepareExecution() when there is
+// Mounts the --vfs-load source. Called from prepareExecution() when there is
// no --import, and otherwise from run_main after the --import loop has run; the
-// guard makes the second call a no-op so a provider registered by either a -r or
-// an --import preload is available before its source's provider is chosen.
+// guard makes the second call a no-op so a provider registered by either a -r
+// or an --import preload is available before the source's provider is chosen.
function finishVfsMounts() {
if (vfsMounted) return;
vfsMounted = true;
- const entries = getOptionValue('--vfs-mount');
- if (entries.length === 0) return;
- emitExperimentalWarning('--vfs-mount');
+ const source = getOptionValue('--vfs-load');
+ if (source === '') return;
+ emitExperimentalWarning('--vfs-load');
const fs = require('fs');
const path = require('path');
const { selectProvider } = require('internal/vfs/provider_registry');
- const { VirtualFileSystem } = require('internal/vfs/file_system');
-
- // --vfs-load is forced off in workers (see node_worker.cc), so this records a
- // load root only on the main thread; a worker re-mounts the same sources in
- // the same order (the reserved paths line up) but runs its own entry.
- const loadIndex = getVfsLoadIndex(entries.length);
- for (let i = 0; i < entries.length; i++) {
- const resolvedSource = path.resolve(entries[i]);
- let stats;
- try {
- stats = fs.statSync(resolvedSource);
- } catch {
- throw new ERR_VFS_INVALID_TARGET(resolvedSource);
- }
- if (!stats.isDirectory() && !stats.isFile()) {
- throw new ERR_VFS_INVALID_TARGET(resolvedSource);
- }
- const provider = selectProvider(resolvedSource, stats);
- if (provider === null) {
- throw new ERR_VFS_INVALID_TARGET(resolvedSource);
- }
- const vfs = new VirtualFileSystem(provider, { emitExperimentalWarning: false });
- const mountPoint = vfs.mount();
- // The mount --vfs-load contributed is what the entry is require()d from;
- // process.argv[1] names the real source instead, since the reserved mount
- // point is an opaque implementation detail.
- //
- // The source is spliced in rather than assigned over argv[1]: the entry
- // comes from the mount, so nothing was consumed as an entry point and the
- // first positional argument is the program's own. Overwriting would drop it.
- if (i === loadIndex) {
- vfsLoadRoot = mountPoint;
- ArrayPrototypeSplice(process.argv, 1, 0, resolvedSource);
- }
+ const { VirtualFileSystem, kLoadLayer } = require('internal/vfs/file_system');
+
+ const resolvedSource = path.resolve(source);
+ let stats;
+ try {
+ stats = fs.statSync(resolvedSource);
+ } catch {
+ throw new ERR_VFS_INVALID_TARGET(resolvedSource);
+ }
+ if (!stats.isDirectory() && !stats.isFile()) {
+ throw new ERR_VFS_INVALID_TARGET(resolvedSource);
+ }
+ const provider = selectProvider(resolvedSource, stats);
+ if (provider === null) {
+ throw new ERR_VFS_INVALID_TARGET(resolvedSource);
+ }
+ // The source is mounted at the layer reserved for it, so it is at the same
+ // reserved path in every thread.
+ const vfs = new VirtualFileSystem(provider, {
+ __proto__: null,
+ emitExperimentalWarning: false,
+ [kLoadLayer]: true,
+ });
+ const mountPoint = vfs.mount();
+ // A worker inherits the source but not [vfs_load_set] (see node_worker.cc):
+ // it mounts the same source, and so reaches it at the same reserved path,
+ // but runs its own entry point.
+ //
+ // On the main thread the entry is require()d from the mount point, while
+ // process.argv[1] reports the source, since the reserved mount point is an
+ // opaque implementation detail. The source is spliced in rather than
+ // assigned over argv[1]: the entry comes from the mount, so nothing was
+ // consumed as an entry point and the first positional argument is the
+ // program's own. Overwriting would drop it.
+ if (getOptionValue('[vfs_load_set]')) {
+ vfsLoadRoot = mountPoint;
+ ArrayPrototypeSplice(process.argv, 1, 0, resolvedSource);
}
}
diff --git a/lib/internal/vfs/file_system.js b/lib/internal/vfs/file_system.js
index afb5fab3eb7..a496bd32efa 100644
--- a/lib/internal/vfs/file_system.js
+++ b/lib/internal/vfs/file_system.js
@@ -47,8 +47,14 @@ const kNormalizedMountPoint = Symbol('kNormalizedMountPoint');
const kMounted = Symbol('kMounted');
const kPromises = Symbol('kPromises');
const kLayerId = Symbol('kLayerId');
+const kLoadLayer = Symbol('kLoadLayer');
-let nextLayerId = 0;
+// Layer 0 is reserved for the file system --vfs-load mounts, so that source is
+// at the same mount point in every thread whatever else a thread mounts, and a
+// path into it stays valid in a worker. Everything else is numbered from 1, in
+// the order it is mounted.
+const kLoadLayerId = 0;
+let nextLayerId = kLoadLayerId + 1;
/**
* Normalizes paths for mount-point comparisons. On Windows, `path.resolve('/x')`
@@ -120,7 +126,7 @@ class VirtualFileSystem {
this[kNormalizedMountPoint] = null;
this[kMounted] = false;
this[kPromises] = null;
- this[kLayerId] = nextLayerId++;
+ this[kLayerId] = options[kLoadLayer] === true ? kLoadLayerId : nextLayerId++;
}
/**
@@ -1309,5 +1315,6 @@ class VirtualFileSystem {
module.exports = {
VirtualFileSystem,
kLayerId,
+ kLoadLayer,
normalizeMountedPath,
};
diff --git a/lib/internal/vfs/provider_registry.js b/lib/internal/vfs/provider_registry.js
index f74f82b4a6a..19d0d4c5451 100644
--- a/lib/internal/vfs/provider_registry.js
+++ b/lib/internal/vfs/provider_registry.js
@@ -1,6 +1,6 @@
'use strict';
-// Maps a `--vfs-mount` source to the provider that backs it. Directories are
+// Maps a `--vfs-load` source to the provider that backs it. Directories are
// served by RealFSProvider and ZIP archives by ZipProvider; both are claimed
// from the source itself (a stat, or a trial open) rather than its file
// extension. Any other source type is added via node:vfs's registerProvider().
@@ -57,7 +57,7 @@ const providers = [
];
/**
- * Registers a provider that `--vfs-mount` can select for a source it
+ * Registers a provider that `--vfs-load` can select for a source it
* recognizes. The newest registration is consulted first, and all registered
* providers outrank the built-in directory provider, so a custom provider can
* back, wrap, or vet any mount.
diff --git a/src/node.cc b/src/node.cc
index e1021ebd27b..2993fda0e9c 100644
--- a/src/node.cc
+++ b/src/node.cc
@@ -1036,25 +1036,16 @@ static ExitCode InitializeNodeWithArgsInternal(
CheckGlobalBenchOptions(errors);
if (!errors->empty()) return ExitCode::kInvalidCommandLineArgument;
- // Checked here rather than in EnvironmentOptions::CheckOptions(), which runs
- // at the end of every parse: NODE_OPTIONS is parsed before the command line,
- // so a check there would reject `NODE_OPTIONS=--vfs-mount=x node
- // --experimental-vfs` for an --experimental-vfs it had not read yet. These
- // options only make sense as a set, so they are validated once all of them
- // are in.
+ // Checked here, once every source of options has been parsed, because the
+ // count below needs the arguments the command line itself gave.
{
auto* env_options = per_process::cli_options->per_isolate->per_env.get();
- if (!env_options->experimental_vfs) {
- if (!env_options->vfs_mounts.empty()) {
- errors->push_back("--vfs-mount requires --experimental-vfs");
- }
- if (env_options->vfs_load) {
- errors->push_back("--vfs-load requires --experimental-vfs");
- }
+ if (!env_options->experimental_vfs && env_options->vfs_load) {
+ errors->push_back("--vfs-load requires --experimental-vfs");
}
- // --vfs-load shares vfs_mounts with --vfs-mount, so the options themselves
- // cannot say how often it was given; count it in the node options the
- // command line yielded. A second one would silently win over the first.
+ // A second --vfs-load would silently replace the first, and the option
+ // itself cannot say how often it was given; count it in the node options
+ // the command line yielded.
if (env_options->vfs_load && exec_argv != nullptr) {
size_t seen = 0;
for (const std::string& arg : *exec_argv) {
diff --git a/src/node_options.cc b/src/node_options.cc
index 6dcdfd6d1bd..692ac3b6240 100644
--- a/src/node_options.cc
+++ b/src/node_options.cc
@@ -691,23 +691,16 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
"experimental node:vfs module",
BOOL_FIELD(experimental_vfs),
kAllowedInEnvvar);
- // --vfs-mount and --vfs-load both append to vfs_mounts, so the list holds
- // every mount in the order the command line asked for them. Which of those
- // the entry point comes from is recovered from the position of --vfs-load,
- // rather than an index the user has to count out.
- AddOption("--vfs-mount",
- "mount a directory or archive as a virtual file system "
- "(option can be repeated; requires --experimental-vfs)",
- &EnvironmentOptions::vfs_mounts,
- kAllowedInEnvvar);
// Choosing the entry point is the command line's alone: an environment
// variable must not be able to redirect what a `node <args>` invocation runs,
- // so this is rejected in NODE_OPTIONS.
+ // so this is rejected in NODE_OPTIONS. The source and whether to run from it
+ // are separate fields so that a worker can inherit the mount without
+ // inheriting the entry point.
AddOption("--vfs-load",
"mount a directory or archive as a virtual file system and run the "
"entry point and module resolution against it instead of the real "
"file system (may be given once; requires --experimental-vfs)",
- &EnvironmentOptions::vfs_mounts,
+ &EnvironmentOptions::vfs_load_source,
kDisallowedInEnvvar);
AddOption("[vfs_load_set]", "", BOOL_FIELD(vfs_load));
Implies("--vfs-load", "[vfs_load_set]");
diff --git a/src/node_options.h b/src/node_options.h
index cad2e850e31..9f31af1cb1b 100644
--- a/src/node_options.h
+++ b/src/node_options.h
@@ -152,6 +152,7 @@ class EnvironmentOptions : public Options {
std::string tls_keylog;
std::string experimental_config_file_path;
std::string experimental_package_map_path;
+ std::string vfs_load_source;
#if HAVE_INSPECTOR
std::string cpu_prof_dir;
std::string cpu_prof_name;
@@ -178,7 +179,6 @@ class EnvironmentOptions : public Options {
std::vector<std::string> watch_mode_paths;
std::vector<std::string> preload_cjs_modules;
std::vector<std::string> preload_esm_modules;
- std::vector<std::string> vfs_mounts;
std::vector<std::string> user_argv;
int64_t heap_snapshot_near_heap_limit = 0;
diff --git a/src/node_worker.cc b/src/node_worker.cc
index a90495409d1..308d987680d 100644
--- a/src/node_worker.cc
+++ b/src/node_worker.cc
@@ -705,8 +705,8 @@ void Worker::New(const FunctionCallbackInfo<Value>& args) {
}
// --vfs-load selects the main thread's entry point; a worker always starts
- // from its own entry (which may itself live inside a --vfs-mount), so the
- // mounts are inherited but the load behavior must not be.
+ // from its own entry (which may itself live inside the mount), so the mount
+ // is inherited but the load behavior must not be.
per_isolate_opts->per_env->vfs_load = false;
// Internal workers should not wait for inspector frontend to connect or
diff --git a/test/parallel/test-vfs-layer-tag-prefix.mjs b/test/parallel/test-vfs-layer-tag-prefix.mjs
index 8c751cee73f..c4083e53999 100644
--- a/test/parallel/test-vfs-layer-tag-prefix.mjs
+++ b/test/parallel/test-vfs-layer-tag-prefix.mjs
@@ -13,22 +13,26 @@ import vfs from 'node:vfs';
const vfsImport = (path) => pathToFileURL(path).href;
-// Layer ids are per-process and increment on every `vfs.create()`, so the
-// second construction lands at id 1 and the eleventh at id 10.
-const instances = [];
-for (let i = 0; i < 11; i++) instances.push(vfs.create());
-const layerOne = instances[1];
-const layerTen = instances[10];
-
-layerOne.writeFileSync('/m.mjs', 'export const tag = "one";');
-const mountOne = layerOne.mount();
-
-layerTen.writeFileSync('/m.mjs', 'export const tag = "ten";');
-const mountTen = layerTen.mount();
-
-assert.notStrictEqual(mountOne, mountTen);
-assert.ok(mountTen.startsWith(mountOne),
- 'test scaffolding: expected layerTen mount to start with layerOne mount');
+// Layer ids are per-process and increment on every `vfs.create()`, so mounting
+// enough instances yields a pair whose mount points collide by prefix (an id
+// and that id followed by another digit), whichever id the numbering starts at.
+const mounted = [];
+for (let i = 0; i < 12; i++) {
+ const layer = vfs.create();
+ mounted.push({ layer, mountPoint: layer.mount() });
+}
+const pair = mounted.flatMap((shorter) =>
+ mounted.filter((longer) => longer !== shorter &&
+ longer.mountPoint.startsWith(shorter.mountPoint))
+ .map((longer) => [shorter, longer]))[0];
+assert.ok(pair, 'test scaffolding: expected a pair of mount points that collide by prefix');
+const [{ layer: layerOne, mountPoint: mountOne },
+ { layer: layerTen, mountPoint: mountTen }] = pair;
+for (const { layer, mountPoint } of mounted) {
+ if (layer !== layerOne && layer !== layerTen) layer.unmount();
+ else layer.writeFileSync(`${mountPoint}/m.mjs`,
+ `export const tag = "${layer === layerOne ? 'one' : 'ten'}";`);
+}
const oneA = await import(vfsImport(`${mountOne}/m.mjs`));
const tenA = await import(vfsImport(`${mountTen}/m.mjs`));
diff --git a/test/parallel/test-vfs-mount-load.js b/test/parallel/test-vfs-load.js
similarity index 59%
rename from test/parallel/test-vfs-mount-load.js
rename to test/parallel/test-vfs-load.js
index 21a05ed24c0..09db73c8fac 100644
--- a/test/parallel/test-vfs-mount-load.js
+++ b/test/parallel/test-vfs-load.js
@@ -1,10 +1,10 @@
'use strict';
-// Covers --vfs-mount / --vfs-load: running a mounted directory's entry point
-// with require() resolving inside the mount, a provider registered by either a
-// -r (CJS) or an --import (ESM) preload backing a non-directory source, a ZIP
-// archive claimed by the built-in provider, a worker inheriting the mounts,
-// and the position of --vfs-load among the mounts deciding which one runs.
+// Covers --vfs-load: running a mounted directory's entry point with require()
+// resolving inside the mount, a provider registered by either a -r (CJS) or an
+// --import (ESM) preload backing a non-directory source, a ZIP archive claimed
+// by the built-in provider, and a worker reaching the mount, whether it
+// inherits the options or is given them itself.
//
// Native addon loading from a mount is not exercised here (it needs a compiled
// .node), only the startup wiring around it.
@@ -136,56 +136,7 @@ registerProvider({
assert.match(res.stdout, /hello from zip archive/);
}
-// Two different ZIP archives mounted together each keep their own contents.
-// The built-in provider opens the archive while deciding whether it can claim
-// the source and hands that same handle to the provider it then creates, so
-// this pins down that the handle belongs to the source it was opened for and
-// is not shared between mounts.
-{
- const zlib = require('zlib');
-
- // Each archive prints which one it is and what it can see, so a mix-up shows
- // up as the wrong marker or the other archive's file.
- const body = Buffer.from(
- 'const fs = require("fs");\n' +
- 'console.log("marker:" + fs.readFileSync(__dirname + "/marker.txt", "utf8").trim());\n' +
- 'console.log("entries:" + fs.readdirSync(__dirname).sort().join(","));\n');
-
- function archive(name, unique) {
- const zipPath = fixture(`${name}.zip`);
- const entries = [
- zlib.ZipEntry.createSync('index.js', body),
- zlib.ZipEntry.createSync('marker.txt', Buffer.from(`${name}\n`)),
- zlib.ZipEntry.createSync(unique, Buffer.from('x\n')),
- ];
- const chunks = [];
- for (const chunk of zlib.createZipArchiveSync(entries)) chunks.push(chunk);
- fs.writeFileSync(zipPath, Buffer.concat(chunks));
- return zipPath;
- }
-
- const first = archive('first-archive', 'first-only.txt');
- const second = archive('second-archive', 'second-only.txt');
-
- // Whichever archive --vfs-load names is the one that runs, in either order,
- // and it sees its own entries rather than the other archive's.
- for (const [args, name, unique, absent] of [
- [[`--vfs-load=${first}`, `--vfs-mount=${second}`],
- 'first-archive', 'first-only.txt', 'second-only.txt'],
- [[`--vfs-mount=${first}`, `--vfs-load=${second}`],
- 'second-archive', 'second-only.txt', 'first-only.txt'],
- [[`--vfs-mount=${second}`, `--vfs-load=${first}`],
- 'first-archive', 'first-only.txt', 'second-only.txt'],
- ]) {
- const res = run(args);
- assert.strictEqual(res.status, 0, res.stderr);
- assert.match(res.stdout, new RegExp(`marker:${name}`));
- assert.match(res.stdout, new RegExp(`entries:.*${unique}`));
- assert.doesNotMatch(res.stdout, new RegExp(absent));
- }
-}
-
-// A worker inherits --vfs-mount, so a worker script that lives inside the mount
+// A worker inherits the mount, so a worker script that lives inside it
// (addressed here via the entry's own __dirname) resolves and runs.
{
const dir = fixture('worker-app');
@@ -235,6 +186,70 @@ parentPort.postMessage('hello from esm worker in mount');
assert.match(res.stdout, /hello from esm worker in mount/);
}
+// A worker created with its own execArgv does not inherit the parent's options,
+// so it has to be given the mount itself. Because the loaded source is always
+// the first file system a thread mounts, it lands at the same reserved mount
+// point in both threads, and a worker path the parent built still resolves.
+{
+ const dir = fixture('worker-execargv-app');
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, 'index.js'), `
+'use strict';
+const path = require('path');
+const { Worker } = require('worker_threads');
+const w = new Worker(path.join(__dirname, 'worker.js'), {
+ execArgv: ['--experimental-vfs', '--vfs-load=' + process.argv[1]],
+});
+w.on('message', (m) => { console.log(m); process.exit(0); });
+w.on('error', (e) => { console.error(e); process.exit(1); });
+`);
+ fs.writeFileSync(path.join(dir, 'worker.js'), `
+'use strict';
+require('worker_threads').parentPort.postMessage('worker ran from ' + __dirname);
+`);
+ // A preload that mounts a file system of its own runs before the --vfs-load
+ // source is mounted, and still does not move it.
+ const preload = fixture('mounting-preload.js');
+ fs.writeFileSync(preload, `
+'use strict';
+require('node:vfs').create().mount();
+`);
+
+ const seen = new Set();
+ for (const args of [[`--vfs-load=${dir}`], ['-r', preload, `--vfs-load=${dir}`]]) {
+ const res = run(args);
+ assert.strictEqual(res.status, 0, res.stderr);
+ const [, dirname] = /worker ran from (\S+)/.exec(res.stdout);
+ seen.add(dirname);
+ }
+ // The worker resolved a path the main thread built, in both runs, and that
+ // path did not move between them.
+ assert.strictEqual(seen.size, 1, [...seen].join());
+}
+
+// Without that flag the worker has no mount to load from, so a script in the
+// mount cannot be its entry point.
+{
+ const dir = fixture('worker-execargv-missing');
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, 'index.js'), `
+'use strict';
+const path = require('path');
+const { Worker } = require('worker_threads');
+const w = new Worker(path.join(__dirname, 'worker.js'), { execArgv: [] });
+w.on('message', (m) => { console.log('ran:' + m); process.exit(0); });
+w.on('error', (e) => { console.log('failed:' + e.code); process.exit(0); });
+`);
+ fs.writeFileSync(path.join(dir, 'worker.js'), `
+'use strict';
+require('worker_threads').parentPort.postMessage('unexpected');
+`);
+ const res = run([`--vfs-load=${dir}`]);
+ assert.strictEqual(res.status, 0, res.stderr);
+ assert.match(res.stdout, /failed:/);
+ assert.doesNotMatch(res.stdout, /ran:/);
+}
+
// --vfs-load names the source it loads, so it always takes a value.
{
const res = run(['--vfs-load']);
@@ -242,54 +257,31 @@ parentPort.postMessage('hello from esm worker in mount');
assert.match(res.stderr, /--vfs-load requires an argument/);
}
-// --vfs-mount and --vfs-load share one ordered list, so mounts happen in the
-// order written and the entry point comes from whichever source --vfs-load
-// names, wherever it sits among them.
+// The value may also be given as a separate argument.
{
- const dirs = {};
- for (const name of ['a', 'b', 'c']) {
- dirs[name] = fixture(name);
- fs.mkdirSync(dirs[name], { recursive: true });
- fs.writeFileSync(path.join(dirs[name], 'index.js'),
- `console.log('ran:${name}');\n`);
- }
-
- for (const [args, expected] of [
- [[`--vfs-load=${dirs.a}`, `--vfs-mount=${dirs.b}`], 'a'],
- [[`--vfs-mount=${dirs.a}`, `--vfs-load=${dirs.b}`, `--vfs-mount=${dirs.c}`], 'b'],
- [[`--vfs-mount=${dirs.a}`, `--vfs-mount=${dirs.b}`, `--vfs-load=${dirs.c}`], 'c'],
- // The value may also be given as a separate argument.
- [['--vfs-mount', dirs.a, '--vfs-load', dirs.b], 'b'],
- ]) {
- const res = run(args);
- assert.strictEqual(res.status, 0, res.stderr);
- assert.match(res.stdout, new RegExp(`ran:${expected}`));
- }
+ const dir = fixture('spaced-value');
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, 'index.js'), "console.log('ran:spaced');\n");
+ const res = run(['--vfs-load', dir]);
+ assert.strictEqual(res.status, 0, res.stderr);
+ assert.match(res.stdout, /ran:spaced/);
}
-// The same source given twice is mounted twice, at two mount points. The entry
-// point comes from the one --vfs-load contributed, not from the earlier mount
-// of the same source.
+// A source whose path holds spaces or quotes is mounted as given. Windows
+// forbids `"` in a file name, so only the spaces and the `$` are exercised
+// there.
{
- const dir = fixture('twice');
+ const oddName = common.isWindows ? `${id++}-od d $x` : `${id++}-od d "q" $x`;
+ const dir = path.join(tmpdir.path, oddName);
fs.mkdirSync(dir, { recursive: true });
- fs.writeFileSync(path.join(dir, 'index.js'),
- 'console.log("dir:" + __dirname);\n');
-
- const res = run([`--vfs-mount=${dir}`, `--vfs-load=${dir}`]);
+ fs.writeFileSync(path.join(dir, 'index.js'), 'console.log("ran:odd");\n');
+ const res = run([`--vfs-load=${dir}`]);
assert.strictEqual(res.status, 0, res.stderr);
- const [, first] = /dir:(\S+)/.exec(res.stdout);
-
- // With the order reversed the entry point is the other mount point, which is
- // what shows that the position decides and not the source.
- const reversed = run([`--vfs-load=${dir}`, `--vfs-mount=${dir}`]);
- assert.strictEqual(reversed.status, 0, reversed.stderr);
- const [, second] = /dir:(\S+)/.exec(reversed.stdout);
- assert.notStrictEqual(first, second);
+ assert.match(res.stdout, /ran:odd/);
}
-// --vfs-load may only be given once: it shares one list with --vfs-mount, so a
-// second one would otherwise quietly win over the first.
+// --vfs-load may only be given once: a second one would otherwise quietly
+// replace the first.
{
const dirs = {};
for (const name of ['once-a', 'once-b']) {
@@ -303,13 +295,6 @@ parentPort.postMessage('hello from esm worker in mount');
`--vfs-load=${dirs['once-b']}`]);
assert.notStrictEqual(twice.status, 0);
assert.match(twice.stderr, /--vfs-load may only be given once/);
-
- // Repeating --vfs-mount stays allowed; only the loading one is limited.
- const many = run([`--vfs-mount=${dirs['once-a']}`,
- `--vfs-load=${dirs['once-b']}`,
- `--vfs-mount=${dirs['once-a']}`]);
- assert.strictEqual(many.status, 0, many.stderr);
- assert.match(many.stdout, /ran:once-b/);
}
// --vfs-load picks the entry point, so it is refused in NODE_OPTIONS: the
@@ -332,74 +317,25 @@ if (hasNodeOptions) {
assert.notStrictEqual(res.status, 0);
assert.match(res.stderr, /--vfs-load.* is not allowed in NODE_OPTIONS/);
}
-
- // --vfs-mount, by contrast, is accepted from the environment.
- const mountFromEnv = spawnSync(
- process.execPath, ['--experimental-vfs', `--vfs-load=${dir}`], {
- encoding: 'utf8',
- env: { ...process.env, NODE_OPTIONS: envArg('--vfs-mount', dir) },
- });
- assert.strictEqual(mountFromEnv.status, 0, mountFromEnv.stderr);
}
-// --experimental-vfs and --vfs-mount may arrive from different places. The
-// options are validated once every source has been parsed, so a mount from
-// NODE_OPTIONS is not rejected for an --experimental-vfs that only the command
-// line carries.
+// --experimental-vfs and --vfs-load may arrive from different places: the
+// options are validated once every source has been parsed, so a --vfs-load on
+// the command line is not rejected for an --experimental-vfs that only
+// NODE_OPTIONS carries.
if (hasNodeOptions) {
- const dir = fixture('env-mount-cli-flag');
+ const dir = fixture('env-flag-cli-load');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'index.js'), 'console.log("ran");\n');
- const res = spawnSync(
- process.execPath, ['--experimental-vfs', `--vfs-load=${dir}`],
- { encoding: 'utf8',
- env: { ...process.env, NODE_OPTIONS: envArg('--vfs-mount', dir) } });
+ const res = spawnSync(process.execPath, [`--vfs-load=${dir}`], {
+ encoding: 'utf8',
+ env: { ...process.env, NODE_OPTIONS: '--experimental-vfs' },
+ });
assert.strictEqual(res.status, 0, res.stderr);
assert.match(res.stdout, /ran/);
}
-// --vfs-mount is allowed in NODE_OPTIONS and adds to the same ordered list.
-// Because --vfs-load names its source rather than counting a position, it no
-// longer matters that the environment is parsed first: what the command line
-// loads is unaffected by how many mounts the environment contributed.
-if (hasNodeOptions) {
- const dirs = {};
- for (const name of ['envA', 'cliX']) {
- dirs[name] = fixture(name);
- fs.mkdirSync(dirs[name], { recursive: true });
- fs.writeFileSync(path.join(dirs[name], 'index.js'),
- `console.log('ran:${name}');\n`);
- }
-
- const res = spawnSync(
- process.execPath, ['--experimental-vfs', `--vfs-load=${dirs.cliX}`], {
- encoding: 'utf8',
- env: { ...process.env, NODE_OPTIONS: envArg('--vfs-mount', dirs.envA) },
- });
- assert.strictEqual(res.status, 0, res.stderr);
- assert.match(res.stdout, /ran:cliX/);
-}
-
-// A mount source holding spaces or quotes survives NODE_OPTIONS when quoted,
-// which is the only way such a path can be expressed there at all. Windows
-// forbids `"` in a file name, so only the spaces and the `$` can be exercised
-// there; the quote escaping itself stays covered on every other platform.
-if (hasNodeOptions) {
- const oddName = common.isWindows ? `${id++}-od d $x` : `${id++}-od d "q" $x`;
- const dir = path.join(tmpdir.path, oddName);
- fs.mkdirSync(dir, { recursive: true });
- fs.writeFileSync(path.join(dir, 'index.js'), 'console.log("ran:odd");\n');
-
- const res = spawnSync(
- process.execPath, ['--experimental-vfs', `--vfs-load=${dir}`], {
- encoding: 'utf8',
- env: { ...process.env, NODE_OPTIONS: envArg('--vfs-mount', dir) },
- });
- assert.strictEqual(res.status, 0, res.stderr);
- assert.match(res.stdout, /ran:odd/);
-}
-
// Under --vfs-load the entry point comes from the mount, so no positional
// argument is consumed as one: every positional reaches the program verbatim
// from argv[2] onward, and argv[1] reports the mounted source.