From c9abbb857ca85c41cc600adff5c3984d1e2e1929 Mon Sep 17 00:00:00 2001 From: Manjeet Singh <024manjeetsingh@gmail.com> Date: Sat, 7 Nov 2026 23:13:06 +0530 Subject: [PATCH 1/2] sea: mount the SEA VFS in workers at layer 0 A single executable application built with "useVfs" mounted the VFS serving its "assets" in the main thread only, at a mount point numbered in mount order, so a worker started from a script among the assets could not load it. On Windows this failed as an invalid package.json at \.\nul\package.json, because paths under the NUL device open it. "useVfs" is the --vfs-load of a single executable application: its "assets" or its "vfsArchive" archive are the source, and its "main" is the entry point. Mount it at layer 0, the mount point reserved for the --vfs-load source, whichever provider serves it, and mount it in workers too, as was already done for an archive. Other mounts are numbered from 1, as everywhere else. To keep layer 0 free, reject --vfs-load in every single executable application rather than only in one built with "vfsArchive": its entry point is fixed, and resolving modules against its files is what "useVfs" already does. --build-sea refuses it in "execArgv", and the executable at startup when it comes from --node-options or from the execArgv of a worker. A worker started from a path into a VFS that is not mounted in it, such as one mounted with vfs.mount() in its parent, now fails with ERR_VFS_NOT_MOUNTED. Fixes: https://github.com/nodejs/node/issues/67223 Assisted-by: a coding agent Signed-off-by: Manjeet Singh <024manjeetsingh@gmail.com> --- doc/api/cli.md | 6 +-- doc/api/errors.md | 15 +++++++ doc/api/single-executable-applications.md | 41 +++++++++++++---- doc/node.1 | 6 +-- lib/internal/errors.js | 2 + lib/internal/main/worker_thread.js | 12 ++++- lib/internal/vfs/file_system.js | 9 ++-- lib/internal/vfs/sea.js | 20 ++++----- lib/internal/vfs/sea_compile_cache.js | 4 +- lib/internal/vfs/setup.js | 23 ++++++++++ src/node_options.cc | 18 +++++--- src/node_options.h | 2 +- src/node_sea.cc | 24 ++++++---- src/node_sea.h | 1 + test/fixtures/sea/vfs-zip/sea.js | 2 +- .../sea/vfs/sea-config-code-cache.json | 1 + .../sea/vfs/sea-config-vfs-load-rejected.json | 9 ++++ test/fixtures/sea/vfs/sea-config.json | 1 + test/fixtures/sea/vfs/sea.js | 25 ++++++++++- test/fixtures/sea/vfs/worker.js | 12 +++++ test/parallel/test-vfs-worker-not-mounted.js | 45 +++++++++++++++++++ ...test-build-sea-vfs-incompatible-options.js | 44 ++++++++++-------- ...e-executable-application-vfs-code-cache.js | 9 ++-- ...t-single-executable-application-vfs-zip.js | 7 ++- .../test-single-executable-application-vfs.js | 21 +++++++++ 25 files changed, 279 insertions(+), 80 deletions(-) create mode 100644 test/fixtures/sea/vfs/sea-config-vfs-load-rejected.json create mode 100644 test/fixtures/sea/vfs/worker.js create mode 100644 test/parallel/test-vfs-worker-not-mounted.js diff --git a/doc/api/cli.md b/doc/api/cli.md index 415b65ac5efa..12caa0185d8c 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -3996,9 +3996,9 @@ comes from anywhere else, such as the real file system, needs nothing added. the command line's decision, and the environment must not be able to redirect it. -A [single executable application][] built with `"vfsArchive"` mounts that -archive at the mount point reserved for `--vfs-load`, so `--vfs-load` cannot be -used in it. +`--vfs-load` cannot be used in a [single executable application][]: its entry +point is the `"main"` it was built with, and with `"useVfs"` its own virtual +file system is mounted at the mount point reserved for `--vfs-load`. ```console $ node --vfs-load=app.zip diff --git a/doc/api/errors.md b/doc/api/errors.md index 8be520b7e521..5661b529be3c 100644 --- a/doc/api/errors.md +++ b/doc/api/errors.md @@ -3598,6 +3598,17 @@ entry types are found. A `--vfs-load` source does not exist, is neither a regular file nor a directory, or is a source no provider claims. + + +### `ERR_VFS_NOT_MOUNTED` + +A path into a virtual file system was used in a thread in which that virtual +file system is not mounted. Each thread has its own mounts: a virtual file +system mounted with [`vfs.mount()`][] is not mounted in the [`Worker`][] +threads, so a worker cannot be started from a script in it. The source +mounted with [`--vfs-load`][] and the virtual file system of a +[single executable application][] are mounted in every thread. + ### `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING` @@ -4787,6 +4798,7 @@ An error occurred trying to allocate memory. This should never happen. [`--force-fips`]: cli.md#--force-fips [`--no-addons`]: cli.md#--no-addons [`--unhandled-rejections`]: cli.md#--unhandled-rejectionsmode +[`--vfs-load`]: cli.md#--vfs-loadsource [`ABORT_ERR`]: #abort_err [`BoundSocket`]: net.md#class-netboundsocket [`Class: DOMException`]: globals.md#class-domexception @@ -4805,6 +4817,7 @@ An error occurred trying to allocate memory. This should never happen. [`REPL`]: repl.md [`ServerResponse`]: http.md#class-httpserverresponse [`Temporal`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal +[`Worker`]: worker_threads.md#class-worker [`Writable`]: stream.md#class-streamwritable [`ZipBuffer`]: zlib.md#class-zlibzipbuffer [`ZipFile`]: zlib.md#class-zlibzipfile @@ -4868,6 +4881,7 @@ An error occurred trying to allocate memory. This should never happen. [`util.inspect()`]: util.md#utilinspectobject-options [`util.parseArgs()`]: util.md#utilparseargsconfig [`v8.startupSnapshot.setDeserializeMainFunction()`]: v8.md#v8startupsnapshotsetdeserializemainfunctioncallback-data +[`vfs.mount()`]: vfs.md#vfsmount [`zlib`]: zlib.md [crypto digest algorithm]: crypto.md#cryptogethashes [debugger]: debugger.md @@ -4878,6 +4892,7 @@ An error occurred trying to allocate memory. This should never happen. [package map]: packages.md#package-maps [relative URL]: https://url.spec.whatwg.org/#relative-url-string [self-reference a package using its name]: packages.md#self-referencing-a-package-using-its-name +[single executable application]: single-executable-applications.md#virtual-file-system-vfs-for-assets [special scheme]: https://url.spec.whatwg.org/#special-scheme [stream-based]: stream.md [syscall]: https://man7.org/linux/man-pages/man2/syscalls.2.html diff --git a/doc/api/single-executable-applications.md b/doc/api/single-executable-applications.md index dcef69a7ee95..2548eaa5dd6b 100644 --- a/doc/api/single-executable-applications.md +++ b/doc/api/single-executable-applications.md @@ -264,6 +264,33 @@ Module format detection works the same way as on the real file system: name bundled ES modules with the `.mjs` extension (or provide the relevant `package.json` files as assets) so they are interpreted as ESM. +#### Worker threads + +The virtual file system is mounted at the mount point reserved for +[`--vfs-load`][], which is the same in every thread, and worker threads mount +it as well. A path into it, such as the main script's +`__filename` or a worker script bundled as an asset, can therefore be loaded +in a [`Worker`][], which reaches the other assets the same way the main +script does: + +```cjs +const { Worker } = require('node:worker_threads'); +const path = require('node:path'); + +// worker.js is bundled as an asset, at the root of the mount. +new Worker(path.join(__dirname, 'worker.js')); +``` + +A virtual file system mounted with [`vfs.mount()`][] in one thread is not +mounted in the others, so a worker cannot load a script from it. + +`useVfs` does for a single executable application what [`--vfs-load`][] does +for `node`: the entry point is its `"main"`, and modules resolve against its +own files. `--vfs-load` therefore cannot be used in a single executable +application: `--build-sea` rejects it in `"execArgv"`, and the executable +rejects it at startup when it comes from `--node-options` or from the +`execArgv` of a worker. + #### Serving the assets from a ZIP archive with `"vfsArchive"` Instead of listing individual `"assets"`, the configuration can point @@ -299,10 +326,9 @@ executable only embeds the archive; read the files through the file system APIs instead. `"vfsArchive"` requires `"useVfs": true` and cannot be combined with `"assets"`. -The archive is mounted at the mount point reserved for [`--vfs-load`][], which -is the same in every thread, and worker threads mount it as well. A path into -the archive, such as the main script's `__filename`, can therefore be loaded -in a worker: +The archive is mounted at the same mount point as the assets would be, and +in [worker threads](#worker-threads) as well. A path into the archive, such +as the main script's `__filename`, can therefore be loaded in a worker: ```cjs const { Worker, isMainThread } = require('node:worker_threads'); @@ -315,11 +341,6 @@ if (isMainThread) { } ``` -Since that mount point is taken by the archive, `--vfs-load` cannot be used -with `"vfsArchive"`: `--build-sea` rejects it in `"execArgv"`, and the -executable rejects it at startup when it comes from `--node-options` or from -the `execArgv` of a worker. - #### Code cache for the modules in the VFS When `"useCodeCache": true` is combined with `"useVfs": true`, the code cache @@ -827,6 +848,7 @@ to help us document them. [VFS documentation]: vfs.md [Windows SDK]: https://developer.microsoft.com/en-us/windows/downloads/windows-sdk/ [`--vfs-load`]: cli.md#--vfs-loadsource +[`Worker`]: worker_threads.md#class-worker [`node:zlib`]: zlib.md [`process.execPath`]: process.md#processexecpath [`require()`]: modules.md#requireid @@ -837,6 +859,7 @@ to help us document them. [`sea.getRawAsset()`]: #seagetrawassetkey [`v8.startupSnapshot.setDeserializeMainFunction()`]: v8.md#v8startupsnapshotsetdeserializemainfunctioncallback-data [`v8.startupSnapshot` API]: v8.md#startup-snapshot-api +[`vfs.mount()`]: vfs.md#vfsmount [documentation about startup snapshot support in Node.js]: cli.md#--build-snapshot [fuse]: https://www.electronjs.org/docs/latest/tutorial/fuses [module loader integration]: vfs.md#module-loader-integration diff --git a/doc/node.1 b/doc/node.1 index 7419eb9a34d2..c90a58b6d45a 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -2040,9 +2040,9 @@ 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. -A single executable application built with \fB"vfsArchive"\fR mounts that -archive at the mount point reserved for \fB--vfs-load\fR, so \fB--vfs-load\fR cannot be -used in it. +\fB--vfs-load\fR cannot be used in a single executable application: its entry +point is the \fB"main"\fR it was built with, and with \fB"useVfs"\fR its own virtual +file system is mounted at the mount point reserved for \fB--vfs-load\fR. .Bd -literal $ node --vfs-load=app.zip .Ed diff --git a/lib/internal/errors.js b/lib/internal/errors.js index e2ee2cdd340d..786fc7e275eb 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -1964,6 +1964,8 @@ 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-load source: must be an existing file or directory', Error); +E('ERR_VFS_NOT_MOUNTED', + '%s is in a virtual file system that is not mounted in this thread', 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 1f5fa8f8212b..ed12eb946789 100644 --- a/lib/internal/main/worker_thread.js +++ b/lib/internal/main/worker_thread.js @@ -11,6 +11,7 @@ const { ObjectDefineProperty, PromisePrototypeThen, RegExpPrototypeExec, + StringPrototypeIncludes, } = primordials; const { @@ -153,9 +154,9 @@ port.on('message', (message) => { if (getOptionValue('--import').length === 0) { finishVfsMounts(); } - // Unlike the --vfs-load source, a SEA archive needs no provider that a + // Unlike the --vfs-load source, the SEA VFS needs no provider that a // preload could register, so its mount is never deferred. - if (getOptionValue('[sea_vfs_archive]')) { + if (getOptionValue('[sea_vfs]')) { require('internal/vfs/sea').initSeaVfsInWorker(); } } @@ -247,6 +248,13 @@ port.on('message', (message) => { runWebWorkerScript(source, webWorkerData); break; } + // A worker does not share the VFS mounts of its parent. Report a path + // into one that is not mounted here instead of letting the real file + // system answer for it. Every such path contains the reserved `vfs` + // directory, which spares other workers from loading the VFS. + if (StringPrototypeIncludes(filename, 'vfs')) { + require('internal/vfs/setup').assertVFSPathMounted(filename); + } // script filename // runMain here might be monkey-patched by users in --require. // XXX: the monkey-patchability here should probably be deprecated. diff --git a/lib/internal/vfs/file_system.js b/lib/internal/vfs/file_system.js index 743ba6d81e16..4982542239ea 100644 --- a/lib/internal/vfs/file_system.js +++ b/lib/internal/vfs/file_system.js @@ -55,10 +55,11 @@ const kLayerId = Symbol('kLayerId'); const kReservedRoot = Symbol('kReservedRoot'); const kLoadLayer = Symbol('kLoadLayer'); -// 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. +// Layer 0 is reserved for the file system the process boots with: the source +// --vfs-load mounts, or the virtual file system of a single executable +// application, which cannot use --vfs-load. It is then 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; diff --git a/lib/internal/vfs/sea.js b/lib/internal/vfs/sea.js index c6aa19314533..9599717f1415 100644 --- a/lib/internal/vfs/sea.js +++ b/lib/internal/vfs/sea.js @@ -48,9 +48,8 @@ function initSeaVfs(mainCode) { const { VirtualFileSystem, kLoadLayer } = require('internal/vfs/file_system'); const extraFiles = { __proto__: null, [getSeaMainName()]: mainCode }; - const isArchive = isVfsArchiveEnabled(); let provider; - if (isArchive) { + if (isVfsArchiveEnabled()) { provider = createZipProvider(extraFiles); } else { const { SEAProvider } = require('internal/vfs/providers/sea'); @@ -58,12 +57,13 @@ function initSeaVfs(mainCode) { } // The SEA warning already covers the feature; don't emit the // VirtualFileSystem experimental warning for the implicit SEA mount. - // An archive is mounted where --vfs-load mounts its source, which is the - // same mount point in every thread, so a worker can reach the code in it. + // "useVfs" is the --vfs-load of a SEA: whichever provider serves it, the + // VFS is mounted where --vfs-load mounts its source, which is the same mount + // point in every thread, so a worker can reach the code in it. const vfs = new VirtualFileSystem(provider, { __proto__: null, emitExperimentalWarning: false, - [kLoadLayer]: isArchive, + [kLoadLayer]: true, }); vfs.mount(); @@ -71,14 +71,12 @@ function initSeaVfs(mainCode) { } /** - * Mounts the SEA virtual file system in a worker, when it is backed by a - * `"vfsArchive"`, so that a path into the mount in the main thread, such as - * the main script's `__filename`, is also valid in the worker. + * Mounts the SEA virtual file system in a worker, so that a path into the + * mount in the main thread, such as the main script's `__filename`, is also + * valid in the worker. */ function initSeaVfsInWorker() { - if (isVfsArchiveEnabled()) { - initSeaVfs(getMainCode()); - } + initSeaVfs(getMainCode()); } /** diff --git a/lib/internal/vfs/sea_compile_cache.js b/lib/internal/vfs/sea_compile_cache.js index 1d77e54f2855..5e6e1b95a3e3 100644 --- a/lib/internal/vfs/sea_compile_cache.js +++ b/lib/internal/vfs/sea_compile_cache.js @@ -45,7 +45,7 @@ const kTypeScriptExtensions = ['.ts', '.cts', '.mts']; /** * Mounts the assets of the SEA configuration the way the executable mounts * them: the main script at the mount point root, at the layer reserved for - * --vfs-load when they come from an archive (see lib/internal/vfs/sea.js). + * --vfs-load (see lib/internal/vfs/sea.js). * @param {object} options The SEA configuration, see buildSeaCompileCache() * @param {string} mainName The name of the main script at the mount root * @returns {string} The mount point @@ -70,7 +70,7 @@ function mountAssets(options, mainName) { const vfs = new VirtualFileSystem(provider, { __proto__: null, emitExperimentalWarning: false, - [kLoadLayer]: isArchive, + [kLoadLayer]: true, }); if (!isArchive) { diff --git a/lib/internal/vfs/setup.js b/lib/internal/vfs/setup.js index 0827100c2d82..1ed74f5d566c 100644 --- a/lib/internal/vfs/setup.js +++ b/lib/internal/vfs/setup.js @@ -22,6 +22,7 @@ const { ERR_INVALID_ARG_TYPE, ERR_INVALID_STATE, ERR_MODULE_NOT_FOUND, + ERR_VFS_NOT_MOUNTED, }, } = require('internal/errors'); const { createENOENT, createEROFS, createEXDEV } = require('internal/vfs/errors'); @@ -176,6 +177,27 @@ function findVFSOrRoot(inputPath) { return { vfs, normalized }; } +/** + * Throws if `filename` is under the reserved VFS root but no layer mounted in + * this thread owns it. Each thread has its own mounts, so this is the case of + * a path a worker is handed into a VFS its parent mounted. Left to the native + * loader, such a path fails confusingly: on Windows, where the root sits under + * `\\.\nul`, the NUL device is found as the file and as an empty, invalid + * package.json above it. + * @param {string} filename + */ +function assertVFSPathMounted(filename) { + const normalized = normalizeMountedPath(filename); + const root = getNormalizedVfsRoot(); + if (normalized !== root && !StringPrototypeStartsWith(normalized, root + sep)) { + return; + } + const layerId = getLayerIdFromPath(normalized); + if (layerId === -1 || !activeVFSLayers.has(layerId)) { + throw new ERR_VFS_NOT_MOUNTED(filename); + } +} + /** * Resolves a path string to the VFS that serves it for the fs functions, * or null for a path outside the reserved VFS root. Unlike the loader, the @@ -1164,6 +1186,7 @@ function uninstallHooks() { } module.exports = { + assertVFSPathMounted, registerVFS, deregisterVFS, }; diff --git a/src/node_options.cc b/src/node_options.cc index e141fab9e823..946330093954 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -320,13 +320,17 @@ void EnvironmentOptions::CheckOptions(std::vector* errors, } #ifndef DISABLE_SINGLE_EXECUTABLE_APPLICATION - sea_vfs_archive = sea::IsSingleExecutable() && - sea::FindSingleExecutableResource().use_vfs_archive(); - // The archive takes the mount point reserved for the --vfs-load source. - if (!vfs_load_source.empty() && sea_vfs_archive) { + // "useVfs" is the --vfs-load of a SEA, whichever provider serves the VFS, + // and takes the mount point reserved for the --vfs-load source. + sea_vfs = sea::IsSingleExecutable() && + sea::FindSingleExecutableResource().use_vfs(); + // The entry point of a SEA is its "main", and resolving modules against its + // files is what "useVfs" does, so --vfs-load has nothing to do in any SEA. + // Accepting it from --node-options would also let whoever runs the binary + // change what it resolves, which is why it is disallowed in NODE_OPTIONS. + if (!vfs_load_source.empty() && sea::IsSingleExecutable()) { errors->push_back( - "--vfs-load cannot be used in a single executable application " - "built with \"vfsArchive\""); + "--vfs-load cannot be used in a single executable application"); } #endif // DISABLE_SINGLE_EXECUTABLE_APPLICATION @@ -830,7 +834,7 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { kDisallowedInEnvvar); AddOption("[vfs_load_set]", "", BOOL_FIELD(vfs_load)); Implies("--vfs-load", "[vfs_load_set]"); - AddOption("[sea_vfs_archive]", "", BOOL_FIELD(sea_vfs_archive)); + AddOption("[sea_vfs]", "", BOOL_FIELD(sea_vfs)); AddOption("--experimental-quic", #ifndef OPENSSL_NO_QUIC "experimental QUIC support", diff --git a/src/node_options.h b/src/node_options.h index d28560c5a539..14f0cc024ef8 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -225,7 +225,7 @@ class EnvironmentOptions : public Options { DEFINE_BOOL_FIELD(experimental_stream_iter) = EXPERIMENTALS_DEFAULT_VALUE; DEFINE_BOOL_FIELD(experimental_vfs) = true; DEFINE_BOOL_FIELD(vfs_load) = false; - DEFINE_BOOL_FIELD(sea_vfs_archive) = false; + DEFINE_BOOL_FIELD(sea_vfs) = false; DEFINE_BOOL_FIELD(webstorage) = HAVE_SQLITE; DEFINE_BOOL_FIELD(experimental_dtls) = EXPERIMENTALS_DEFAULT_VALUE; DEFINE_BOOL_FIELD(experimental_quic) = EXPERIMENTALS_DEFAULT_VALUE; diff --git a/src/node_sea.cc b/src/node_sea.cc index b25d0fcb93cc..09e1599e9506 100644 --- a/src/node_sea.cc +++ b/src/node_sea.cc @@ -257,6 +257,10 @@ bool SeaResource::use_code_cache() const { return static_cast(flags & SeaFlags::kUseCodeCache); } +bool SeaResource::use_vfs() const { + return static_cast(flags & SeaFlags::kEnableVfs); +} + bool SeaResource::use_vfs_archive() const { return static_cast(flags & SeaFlags::kVfsArchive); } @@ -622,6 +626,17 @@ std::optional ParseSingleExecutableConfig( } } + // A SEA rejects --vfs-load at startup (see EnvironmentOptions::CheckOptions), + // so do not build one that passes it in "execArgv". + for (const std::string& arg : result.exec_argv) { + if (arg == "--vfs-load" || arg.starts_with("--vfs-load=")) { + FPrintF(stderr, + "--vfs-load cannot be used in \"execArgv\" of a single " + "executable application\n"); + return std::nullopt; + } + } + if (static_cast(result.flags & SeaFlags::kVfsArchive)) { if (!static_cast(result.flags & SeaFlags::kEnableVfs)) { FPrintF(stderr, "\"vfsArchive\" requires \"useVfs\" to be true\n"); @@ -638,15 +653,6 @@ std::optional ParseSingleExecutableConfig( config_path); return std::nullopt; } - // The archive takes the mount point reserved for the --vfs-load source. - for (const std::string& arg : result.exec_argv) { - if (arg == "--vfs-load" || arg.starts_with("--vfs-load=")) { - FPrintF(stderr, - "\"vfsArchive\" cannot be used together with --vfs-load in " - "\"execArgv\"\n"); - return std::nullopt; - } - } // The archive is embedded as a single reserved asset. result.flags |= SeaFlags::kIncludeAssets; } diff --git a/src/node_sea.h b/src/node_sea.h index b4c852eff4eb..c88a5b5daa62 100644 --- a/src/node_sea.h +++ b/src/node_sea.h @@ -64,6 +64,7 @@ struct SeaResource { bool use_snapshot() const; bool use_code_cache() const; + bool use_vfs() const; bool use_vfs_archive() const; static constexpr size_t kHeaderSize = sizeof(kMagic) + sizeof(SeaFlags) + diff --git a/test/fixtures/sea/vfs-zip/sea.js b/test/fixtures/sea/vfs-zip/sea.js index 439728c78e1a..0ce049831433 100644 --- a/test/fixtures/sea/vfs-zip/sea.js +++ b/test/fixtures/sea/vfs-zip/sea.js @@ -74,7 +74,7 @@ assert.notStrictEqual(a, b); a[0] = 0; assert.strictEqual(b.toString('utf8'), 'Hello from SEA VFS!'); -// The mount point is taken by the archive, so a worker cannot --vfs-load. +// --vfs-load cannot be used in a SEA, so neither can a worker. assert.throws(() => new Worker(__filename, { execArgv: ['--experimental-vfs', `--vfs-load=${__dirname}`], }), { code: 'ERR_WORKER_INVALID_EXEC_ARGV' }); diff --git a/test/fixtures/sea/vfs/sea-config-code-cache.json b/test/fixtures/sea/vfs/sea-config-code-cache.json index 7627849eb9de..6ff862cad4a7 100644 --- a/test/fixtures/sea/vfs/sea-config-code-cache.json +++ b/test/fixtures/sea/vfs/sea-config-code-cache.json @@ -5,6 +5,7 @@ "useCodeCache": true, "assets": { "config.json": "config.json", + "worker.js": "worker.js", "data/greeting.txt": "greeting.txt", "modules/math.js": "math.js", "modules/calculator.js": "calculator.js", diff --git a/test/fixtures/sea/vfs/sea-config-vfs-load-rejected.json b/test/fixtures/sea/vfs/sea-config-vfs-load-rejected.json new file mode 100644 index 000000000000..fc450de44fec --- /dev/null +++ b/test/fixtures/sea/vfs/sea-config-vfs-load-rejected.json @@ -0,0 +1,9 @@ +{ + "main": "sea.js", + "output": "sea-vfs-load-rejected", + "useVfs": true, + "assets": { + "config.json": "config.json" + }, + "execArgvExtension": "cli" +} diff --git a/test/fixtures/sea/vfs/sea-config.json b/test/fixtures/sea/vfs/sea-config.json index 767e778ea26e..307ff3653ee5 100644 --- a/test/fixtures/sea/vfs/sea-config.json +++ b/test/fixtures/sea/vfs/sea-config.json @@ -4,6 +4,7 @@ "useVfs": true, "assets": { "config.json": "config.json", + "worker.js": "worker.js", "data/greeting.txt": "greeting.txt", "modules/math.js": "math.js", "modules/calculator.js": "calculator.js", diff --git a/test/fixtures/sea/vfs/sea.js b/test/fixtures/sea/vfs/sea.js index 59989390c7d1..bff660148fc3 100644 --- a/test/fixtures/sea/vfs/sea.js +++ b/test/fixtures/sea/vfs/sea.js @@ -2,6 +2,7 @@ const fs = require('fs'); const path = require('path'); const assert = require('assert'); +const os = require('os'); // The SEA VFS never shadows the real file system: the assets are mounted at // a reserved mount point and the main script runs from inside the mount, so @@ -14,6 +15,10 @@ assert.strictEqual(path.basename(__filename), 'sea.js'); assert.strictEqual(require.main, module); console.log('main script runs from', __filename); +// Like an archive, the assets are mounted at the mount point reserved for +// --vfs-load. +assert.strictEqual(__dirname, path.join(os.devNull, 'vfs', '0')); + // The main script itself is readable through fs. assert.strictEqual(fs.existsSync(__filename), true); @@ -103,4 +108,22 @@ const exportsPkg = require('test-exports-pkg'); assert.strictEqual(exportsPkg.fromExports, true, 'exports-only package should resolve'); console.log('exports-only package lookup test passed'); -console.log('All SEA VFS tests passed!'); +// A worker started from a script among the assets can load it, and the +// assets next to it, from the same mount point as the main thread. +const { Worker } = require('worker_threads'); + +// --vfs-load cannot be used in a SEA, so neither can a worker. +assert.throws(() => new Worker(path.join(__dirname, 'worker.js'), { + execArgv: ['--experimental-vfs', `--vfs-load=${__dirname}`], +}), { code: 'ERR_WORKER_INVALID_EXEC_ARGV' }); + +const worker = new Worker(path.join(__dirname, 'worker.js')); +worker.once('message', (message) => { + assert.deepStrictEqual(message, { + dirname: __dirname, + greeting: 'Hello from SEA VFS!', + sum: 5, + }); + console.log('worker test passed'); + console.log('All SEA VFS tests passed!'); +}); diff --git a/test/fixtures/sea/vfs/worker.js b/test/fixtures/sea/vfs/worker.js new file mode 100644 index 000000000000..47fc8046d1c9 --- /dev/null +++ b/test/fixtures/sea/vfs/worker.js @@ -0,0 +1,12 @@ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const { parentPort } = require('worker_threads'); + +// A worker mounts the assets too, at the same mount point, so a worker script +// among them, and the code and data next to it, can be loaded from there. +parentPort.postMessage({ + dirname: __dirname, + greeting: fs.readFileSync(path.join(__dirname, 'data', 'greeting.txt'), 'utf8'), + sum: require('./modules/math.js').add(2, 3), +}); diff --git a/test/parallel/test-vfs-worker-not-mounted.js b/test/parallel/test-vfs-worker-not-mounted.js new file mode 100644 index 000000000000..46f1ed1fccd6 --- /dev/null +++ b/test/parallel/test-vfs-worker-not-mounted.js @@ -0,0 +1,45 @@ +// Flags: --disable-warning=ExperimentalWarning +'use strict'; + +// A worker does not share the VFS mounts of the thread that starts it, so a +// worker script in a VFS mounted with vfs.mount() cannot be loaded. The worker +// reports that, instead of failing on the real file system (on Windows, as an +// invalid package.json, because paths under the VFS root open the NUL device). + +const common = require('../common'); +const tmpdir = require('../common/tmpdir'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); +const { Worker } = require('worker_threads'); + +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/worker.js', "require('worker_threads').parentPort.postMessage('ran');\n"); + const mountPoint = myVfs.mount(); + const filename = path.join(mountPoint, 'worker.js'); + + const worker = new Worker(filename); + worker.on('message', common.mustNotCall()); + worker.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ERR_VFS_NOT_MOUNTED'); + assert.strictEqual( + err.message, + `${filename} is in a virtual file system that is not mounted in this thread`); + myVfs.unmount(); + })); +} + +// A real worker script whose path merely mentions "vfs" runs as usual. +{ + tmpdir.refresh(); + const filename = tmpdir.resolve('vfs-worker.js'); + fs.writeFileSync(filename, "require('worker_threads').parentPort.postMessage('ran');\n"); + + const worker = new Worker(filename); + worker.on('message', common.mustCall((message) => { + assert.strictEqual(message, 'ran'); + })); + worker.on('error', common.mustNotCall()); +} diff --git a/test/sea/test-build-sea-vfs-incompatible-options.js b/test/sea/test-build-sea-vfs-incompatible-options.js index e2ef11a01458..92666cbf6a5c 100644 --- a/test/sea/test-build-sea-vfs-incompatible-options.js +++ b/test/sea/test-build-sea-vfs-incompatible-options.js @@ -120,23 +120,29 @@ skipIfBuildSEAIsNotSupported(); }); } -// Test: "vfsArchive" combined with --vfs-load in "execArgv" -for (const arg of ['--vfs-load=assets.zip', '--vfs-load']) { - tmpdir.refresh(); - const config = tmpdir.resolve('vfs-archive-vfs-load.json'); - writeFileSync(config, JSON.stringify({ - main: 'bundle.js', - output: 'sea', - useVfs: true, - vfsArchive: 'assets.zip', - execArgv: ['--experimental-vfs', arg], - }), 'utf8'); - spawnSyncAndAssert( - process.execPath, - ['--build-sea', config], { - cwd: tmpdir.path, - }, { - status: 1, - stderr: /"vfsArchive" cannot be used together with --vfs-load in "execArgv"/, - }); +// Test: --vfs-load in "execArgv", which a SEA rejects at startup whether or +// not it uses a VFS, and whatever serves the VFS. +for (const vfsConfig of [ + {}, + { useVfs: true }, + { useVfs: true, vfsArchive: 'assets.zip' }, +]) { + for (const arg of ['--vfs-load=assets.zip', '--vfs-load']) { + tmpdir.refresh(); + const config = tmpdir.resolve('vfs-load.json'); + writeFileSync(config, JSON.stringify({ + main: 'bundle.js', + output: 'sea', + ...vfsConfig, + execArgv: ['--experimental-vfs', arg], + }), 'utf8'); + spawnSyncAndAssert( + process.execPath, + ['--build-sea', config], { + cwd: tmpdir.path, + }, { + status: 1, + stderr: /--vfs-load cannot be used in "execArgv" of a single executable application/, + }); + } } diff --git a/test/sea/test-single-executable-application-vfs-code-cache.js b/test/sea/test-single-executable-application-vfs-code-cache.js index a1666473ce8e..788936879613 100644 --- a/test/sea/test-single-executable-application-vfs-code-cache.js +++ b/test/sea/test-single-executable-application-vfs-code-cache.js @@ -60,9 +60,10 @@ async function generateBlob(fixtureDir, prepare) { } async function main() { - // CommonJS: sea.js, modules/math.js, modules/calculator.js and the two - // packages under node_modules; config.json and greeting.txt are data. - assert.strictEqual(await generateBlob(fixtures.path('sea', 'vfs')), 5); + // CommonJS: sea.js, worker.js, modules/math.js, modules/calculator.js and + // the two packages under node_modules; config.json and greeting.txt are + // data. + assert.strictEqual(await generateBlob(fixtures.path('sea', 'vfs')), 6); // ESM: main.mjs, modules/math.mjs, modules/calculator.cjs and the package. assert.strictEqual(await generateBlob(fixtures.path('sea', 'vfs-esm')), 4); @@ -101,7 +102,7 @@ async function main() { stderr: /Wrote single executable preparation blob/, }); assert.strictEqual( - countCompileCacheEntries(tmpdir.resolve('sea-prep.blob')), 5); + countCompileCacheEntries(tmpdir.resolve('sea-prep.blob')), 6); // The executable runs with the cache embedded, and the reserved assets // are not visible as files of the virtual file system. diff --git a/test/sea/test-single-executable-application-vfs-zip.js b/test/sea/test-single-executable-application-vfs-zip.js index 6165d567de5b..dd30c6f7cf1e 100644 --- a/test/sea/test-single-executable-application-vfs-zip.js +++ b/test/sea/test-single-executable-application-vfs-zip.js @@ -58,9 +58,8 @@ main().then(common.mustCall(() => { }, ); - // The archive is mounted where --vfs-load would mount its source, so the - // executable rejects --vfs-load at startup. --build-sea already refuses it - // in "execArgv", so pass it through --node-options instead. + // A SEA rejects --vfs-load at startup. --build-sea already refuses it in + // "execArgv", so pass it through --node-options instead. const rejectedFile = buildSEA(fixtures.path('sea', 'vfs-zip'), { configPath: 'sea-config-vfs-load-rejected.json', }); @@ -75,7 +74,7 @@ main().then(common.mustCall(() => { }, { status: 9, - stderr: /--vfs-load cannot be used in a single executable application built with "vfsArchive"/, + stderr: /--vfs-load cannot be used in a single executable application/, }, ); })); diff --git a/test/sea/test-single-executable-application-vfs.js b/test/sea/test-single-executable-application-vfs.js index 46a50cd79f86..3b92eab033ff 100644 --- a/test/sea/test-single-executable-application-vfs.js +++ b/test/sea/test-single-executable-application-vfs.js @@ -36,3 +36,24 @@ spawnSyncAndAssert( }, }, ); + +// A SEA rejects --vfs-load at startup, so it cannot take the mount point of +// the assets. --build-sea already refuses it in "execArgv", so pass it through +// --node-options instead. +const rejectedFile = buildSEA(fixtures.path('sea', 'vfs'), { + configPath: 'sea-config-vfs-load-rejected.json', +}); +spawnSyncAndAssert( + rejectedFile, + ['--node-options=--experimental-vfs --vfs-load=.'], + { + env: { + ...process.env, + NODE_DEBUG_NATIVE: undefined, + }, + }, + { + status: 9, + stderr: /--vfs-load cannot be used in a single executable application/, + }, +); From 77c4ffb20fd351aca8927a7c5ca7545f544a032a Mon Sep 17 00:00:00 2001 From: Manjeet Singh <024manjeetsingh@gmail.com> Date: Sun, 8 Nov 2026 00:07:56 +0530 Subject: [PATCH 2/2] sea: address review comments Remove two comments in EnvironmentOptions::CheckOptions() that repeat what the code and the error message already say, and move the note that --vfs-load cannot be used in a single executable application out of the "Worker threads" section, since it applies to every SEA. Refs: https://github.com/nodejs/node/issues/67223 Assisted-by: a coding agent Signed-off-by: Manjeet Singh <024manjeetsingh@gmail.com> --- doc/api/single-executable-applications.md | 14 +++++++------- src/node_options.cc | 6 ------ 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/doc/api/single-executable-applications.md b/doc/api/single-executable-applications.md index 2548eaa5dd6b..72700121aa57 100644 --- a/doc/api/single-executable-applications.md +++ b/doc/api/single-executable-applications.md @@ -223,6 +223,13 @@ The VFS supports the `node:fs` operations for reading files and directories. Since the SEA VFS is read-only, write operations fail with `EROFS`. See the [VFS documentation][] for the full list of supported operations. +`useVfs` does for a single executable application what [`--vfs-load`][] does +for `node`: the entry point is its `"main"`, and modules resolve against its +own files. `--vfs-load` therefore cannot be used in a single executable +application, whether or not it uses `useVfs`: `--build-sea` rejects it in +`"execArgv"`, and the executable rejects it at startup when it comes from +`--node-options` or from the `execArgv` of a worker. + #### Loading modules from the VFS in a SEA When `useVfs` is enabled, the main script is executed from inside the @@ -284,13 +291,6 @@ new Worker(path.join(__dirname, 'worker.js')); A virtual file system mounted with [`vfs.mount()`][] in one thread is not mounted in the others, so a worker cannot load a script from it. -`useVfs` does for a single executable application what [`--vfs-load`][] does -for `node`: the entry point is its `"main"`, and modules resolve against its -own files. `--vfs-load` therefore cannot be used in a single executable -application: `--build-sea` rejects it in `"execArgv"`, and the executable -rejects it at startup when it comes from `--node-options` or from the -`execArgv` of a worker. - #### Serving the assets from a ZIP archive with `"vfsArchive"` Instead of listing individual `"assets"`, the configuration can point diff --git a/src/node_options.cc b/src/node_options.cc index 946330093954..2aee993404ee 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -320,14 +320,8 @@ void EnvironmentOptions::CheckOptions(std::vector* errors, } #ifndef DISABLE_SINGLE_EXECUTABLE_APPLICATION - // "useVfs" is the --vfs-load of a SEA, whichever provider serves the VFS, - // and takes the mount point reserved for the --vfs-load source. sea_vfs = sea::IsSingleExecutable() && sea::FindSingleExecutableResource().use_vfs(); - // The entry point of a SEA is its "main", and resolving modules against its - // files is what "useVfs" does, so --vfs-load has nothing to do in any SEA. - // Accepting it from --node-options would also let whoever runs the binary - // change what it resolves, which is why it is disallowed in NODE_OPTIONS. if (!vfs_load_source.empty() && sea::IsSingleExecutable()) { errors->push_back( "--vfs-load cannot be used in a single executable application");