diff --git a/crates/nub-cli/tests/integration.rs b/crates/nub-cli/tests/integration.rs index d36355f6f..099dd57f7 100644 --- a/crates/nub-cli/tests/integration.rs +++ b/crates/nub-cli/tests/integration.rs @@ -3201,6 +3201,69 @@ fn node_compat_flag_disables_augmentation() { ); } +/// `--experimental-webstorage` must ride ARGV and never the inherited NODE_OPTIONS. +/// The flag does not exist before Node 22.4 — above nub's 18.19 support floor — and +/// NODE_OPTIONS is inherited by the whole process subtree, so a descendant on an older +/// Node aborts at startup with exit 9 on a flag it cannot parse. That is reachable, not +/// theoretical: a host on the 22.4–24 band running Electron 34 or older (embedded Node +/// 20.18.1) hit exactly this, and issue #7 was the same flag reaching an older child +/// through a nested `.nvmrc`. Argv reaches the spawned process and nothing below it. +/// +/// This is the WIRING test: the version-band unit tests in `feature_matrix` and the +/// `flags::should_inject_experimental_webstorage` policy tests all pass just as well +/// with the injection wired to the wrong channel, because none of them observe which +/// channel a real spawn actually used. Both halves are asserted together — absent from +/// NODE_OPTIONS is only meaningful alongside present on argv, since a flag that stopped +/// being injected at all would satisfy the first half on its own. +#[test] +fn webstorage_flag_rides_argv_and_never_node_options() { + if !node_at_least((22, 4, 0)) { + eprintln!("skipping: webstorage needs Node >= 22.4 (target is older)"); + return; + } + let (maj, _, _) = target_node_version(); + if maj >= 25 { + eprintln!("skipping: 25+ has Web Storage native, so nub injects no flag to place"); + return; + } + let dir = std::env::temp_dir().join(format!("nub-ws-channel-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("package.json"), r#"{"name":"ws-channel"}"#).unwrap(); + std::fs::write( + dir.join("probe.js"), + "console.log('OPTS=' + (process.env.NODE_OPTIONS || ''));\n\ + console.log('ARGV=' + process.execArgv.join(' '));", + ) + .unwrap(); + + let out = Command::new(nub_binary()) + .args(["probe.js"]) + .current_dir(&dir) + .env("XDG_CACHE_HOME", dir.join("cache")) + .output() + .expect("failed to spawn nub"); + let stdout = String::from_utf8_lossy(&out.stdout); + let opts = stdout + .lines() + .find_map(|l| l.strip_prefix("OPTS=")) + .unwrap_or_else(|| panic!("probe printed no OPTS line; stdout={stdout:?}")); + let argv = stdout + .lines() + .find_map(|l| l.strip_prefix("ARGV=")) + .unwrap_or_else(|| panic!("probe printed no ARGV line; stdout={stdout:?}")); + + assert!( + argv.contains("--experimental-webstorage"), + "nub must still inject the flag on the 22.4–24 band, on argv; argv={argv:?}" + ); + assert!( + !opts.contains("--experimental-webstorage"), + "the flag must NOT be in NODE_OPTIONS — that string is inherited by the whole \ + subtree and aborts any descendant below Node 22.4; NODE_OPTIONS={opts:?}" + ); +} + /// sessionStorage works OUT OF THE BOX (the maintainer, 2026-06-15): nub always injects /// `--experimental-webstorage` on the 22.4–24 flag-needed band (and 25+ has it /// native), so `sessionStorage` is a working global with no opt-in. localStorage @@ -3447,16 +3510,18 @@ fn user_node_options_localstorage_file_is_not_clobbered() { } /// The localStorage neutralization must reach GRANDCHILDREN, not just the direct -/// child (F1). nub injects `--experimental-webstorage` via NODE_OPTIONS, which -/// inherits to the whole process subtree — so a `node`-spawned grandchild -/// re-installs Node's throwing `localStorage` getter. The neutralize signal -/// (`__NUB_NEUTRALIZE_LOCALSTORAGE`) is a plain env var that also inherits, so the -/// preload re-runs and re-neutralizes at every level. Before the fix the preload -/// DELETED that var after reading it, so the child and grandchild inherited the -/// throwing getter with no neutralize signal → `typeof localStorage` threw two -/// levels down. This fixture has nub run a parent that spawns a plain `node` child -/// that spawns a plain `node` grandchild, all without `--localstorage-file`, and -/// asserts `typeof localStorage === "undefined"` (no throw) at all three levels. +/// child (F1). Each `node` in the chain re-installs Node's throwing `localStorage` +/// getter, because each receives `--experimental-webstorage` itself: the flag rides +/// ARGV (never NODE_OPTIONS, whose 22.4 floor aborted below-floor descendants), so a +/// plain `node` picks it up by re-entering nub through the PATH shim. The neutralize +/// signal (`__NUB_NEUTRALIZE_LOCALSTORAGE`) is a plain env var that inherits, and nub +/// re-sets it wherever it sets the flag, so the preload re-neutralizes at every level. +/// Two regressions this pins: the preload once DELETED that var after reading it, so +/// the child and grandchild inherited the throwing getter with no signal; and the flag +/// and its signal must move together, since a level that gets one without the other +/// throws. This fixture has nub run a parent that spawns a plain `node` child that +/// spawns a plain `node` grandchild, all without `--localstorage-file`, and asserts +/// `typeof localStorage === "undefined"` (no throw) at all three levels. #[test] fn localstorage_neutralization_reaches_grandchildren() { if !node_at_least((22, 4, 0)) { @@ -3474,7 +3539,7 @@ fn localstorage_neutralization_reaches_grandchildren() { // neutralize ran here too. Tag the level so a failure is self-debugging. std::fs::write( dir.join("grandchild.js"), - "console.log('GRANDCHILD:' + typeof localStorage);", + "console.log('GRANDCHILD:' + typeof localStorage + ':' + typeof sessionStorage);", ) .unwrap(); // child.js: prints its own level, then spawns the grandchild as a plain `node` @@ -3482,7 +3547,7 @@ fn localstorage_neutralization_reaches_grandchildren() { // preload via inherited NODE_OPTIONS — exactly the subtree we must cover). std::fs::write( dir.join("child.js"), - "console.log('CHILD:' + typeof localStorage);\n\ + "console.log('CHILD:' + typeof localStorage + ':' + typeof sessionStorage);\n\ const cp = require('node:child_process');\n\ const r = cp.spawnSync('node', [require('node:path').join(__dirname, 'grandchild.js')], { stdio: 'inherit' });\n\ process.exit(r.status ?? 1);", @@ -3491,7 +3556,7 @@ fn localstorage_neutralization_reaches_grandchildren() { // parent.js: top level run by nub. Spawns the child as a plain `node`. std::fs::write( dir.join("parent.js"), - "console.log('PARENT:' + typeof localStorage);\n\ + "console.log('PARENT:' + typeof localStorage + ':' + typeof sessionStorage);\n\ const cp = require('node:child_process');\n\ const r = cp.spawnSync('node', [require('node:path').join(__dirname, 'child.js')], { stdio: 'inherit' });\n\ process.exit(r.status ?? 1);", @@ -3512,9 +3577,15 @@ fn localstorage_neutralization_reaches_grandchildren() { "grandchild chain must not throw at any level: stdout={stdout:?} stderr={stderr:?}" ); for level in ["PARENT", "CHILD", "GRANDCHILD"] { + // `localStorage` neutralized AND `sessionStorage` live, asserted together at + // each level. The second half is a POSITIVE CONTROL and is what makes the + // first half mean anything: on this band a process that never received + // `--experimental-webstorage` also reports `localStorage` as "undefined", so + // the neutralize assertion alone passes just as well when the flag failed to + // arrive at all. `sessionStorage` is "object" only when the flag DID arrive. assert!( - stdout.contains(&format!("{level}:undefined")), - "`typeof localStorage` must be \"undefined\" (not throw) at the {level} level with no --localstorage-file; stdout={stdout:?} stderr={stderr:?}" + stdout.contains(&format!("{level}:undefined:object")), + "at the {level} level `typeof localStorage` must be \"undefined\" (neutralized, not thrown) and `typeof sessionStorage` must be \"object\" (proving --experimental-webstorage actually reached this level); stdout={stdout:?} stderr={stderr:?}" ); } diff --git a/crates/nub-core/src/node/spawn.rs b/crates/nub-core/src/node/spawn.rs index eac576978..d7bae89bf 100644 --- a/crates/nub-core/src/node/spawn.rs +++ b/crates/nub-core/src/node/spawn.rs @@ -965,61 +965,8 @@ pub fn spawn_node(config: &SpawnConfig<'_>) -> Result { // applied below, OUTSIDE this block — see the comment at that site. Applying // them here would skip them on exactly the spawn that needs them most: a // re-entrant one, which is every `node` a script launches through the shim. - - // Web Storage: injected here, NOT through `compute_inject_flags`, so it sits - // OUTSIDE the Stage-4 accepted-flag intersection above. Safe: its band is - // CLOSED (`22.4–<25`) and the flag stabilized (not removed) at 25 — no - // open-ended-removal hazard, so it needs no probe guard. (Any FUTURE - // open-ended flag should go through `compute_inject_flags` to inherit the - // guard, not this direct-injection path.) - // - // nub ALWAYS injects `--experimental-webstorage` on the band - // where that flag is the enabling mechanism (Node 22.4 through <25, i.e. - // `webstorage_flag_needed`), regardless of whether the user opted into - // localStorage persistence (the maintainer, 2026-06-15: "a flag that we inject no - // matter what"). On that band `sessionStorage` needs ONLY the flag (no file) - // — gating it behind a `--localstorage-file` opt-in wrongly broke out-of-the- - // box sessionStorage. So inject the flag unconditionally in-band; this makes - // sessionStorage work everywhere on 22.4–24 and installs the `localStorage` - // getter (which still throws `ERR_INVALID_ARG_VALUE` on ACCESS until the user - // supplies a `--localstorage-file`). Empirically the flag alone does NOT throw - // at startup on 22.4–24, so always-injecting is safe. - // - // nub NEVER synthesizes `--localstorage-file` — localStorage persistence - // stays the user's explicit opt-in (forwarded verbatim if they pass it). - // - // Scope is exactly the `webstorage_flag_needed` band: below 22.4 the flag is - // an unrecognized "bad option" (would crash startup), and on 25+ Web Storage - // is native so the flag is unnecessary. Skip the inject when the user already - // supplied `--experimental-webstorage` / `--no-experimental-webstorage` (no - // double-add; respect an explicit disable — nub never re-enables over a user - // negation). - if flags::should_inject_experimental_webstorage( - &config.node.version, - config.user_args, - node_options.as_deref(), - ) { - cmd.arg("--experimental-webstorage"); - } - - // Web Storage localStorage neutralization: on the band where nub injects - // `--experimental-webstorage` AND the user did NOT supply their own - // `--localstorage-file`, the injected flag installs a `localStorage` getter - // that throws `ERR_INVALID_ARG_VALUE` on access (even `typeof localStorage` - // throws). Signal nub's startup preload to replace that throwing getter with - // a plain `undefined` value — matching Node 25+'s clean shape so - // `typeof localStorage === "undefined"` feature-detection is safe — while - // `sessionStorage` (which needs only the flag) keeps working out of the box. - // When the user passes `--localstorage-file`, this is skipped and - // `localStorage` works normally. The signal is an internal `__NUB_*` env var - // (brand-boundary-permitted plumbing); the preload deletes it after reading. - if flags::should_neutralize_experimental_webstorage_localstorage( - &config.node.version, - config.user_args, - node_options.as_deref(), - ) { - cmd.env(flags::NEUTRALIZE_LOCALSTORAGE_ENV, "1"); - } + // Web Storage's flag and its paired neutralize signal are in that same set, + // and moved out for the same reason — see the site below. // PATH shim: prepend a temp dir with a `node` symlink → nub. if let Ok(shim_dir) = setup_path_shim(config.nub_binary) { @@ -1216,13 +1163,32 @@ pub fn spawn_node(config: &SpawnConfig<'_>) -> Result { // Reuses the NODE_OPTIONS read at the top of the function rather than // re-reading the (constant) env value. // - // Only flags that cannot abort an OLDER descendant ride this channel, with one - // KNOWN RESIDUAL — `--experimental-webstorage`, floor 22.4, still pushed below - // and reachable; see the full note in `compute_augmentation_env` — - // `flags::node_options_safe_inject_flags`, today just `--enable-source-maps` - // (Node 12.12+, below nub's 18.19 floor). The version-gated FEATURE flags do - // NOT, and neither does `--disable-warning=ExperimentalWarning`, whose 20.11 - // floor is above that support floor; they are on argv above, and only there. + // The rule for this channel: a token belongs here only if its floor is at or + // below nub's 18.19 support floor, because NODE_OPTIONS is inherited by the + // whole subtree and a descendant on an older Node aborts on anything it cannot + // parse. That is `flags::node_options_safe_inject_flags` — today just + // `--enable-source-maps` (Node 12.12+). The version-gated FEATURE flags are not + // here, nor is `--disable-warning=ExperimentalWarning` (floor 20.11), nor + // `--experimental-webstorage` (floor 22.4); they ride argv, and only argv. + // + // ONE DELIBERATE EXCEPTION REMAINS: `--test-coverage-exclude` (floor 22.5), + // pushed just below as a single token carrying nub's own runtime glob — and + // deliberately not Node's default test-file pattern, which rides argv instead + // (see that site). It breaks the rule knowingly — a descendant below 22.5 + // aborts on it — because it is the only token here that MUST share a channel + // with the preload: a coverage grandchild nub never spawns inherits the preload + // through this string alone, so an exclude on argv would not reach it and nub's + // own runtime would be instrumented into the user's report. See its own comment + // for the full argument. + // + // KEPT ON PURPOSE (the maintainer, 2026-08-28), asked and answered when the + // webstorage flag was moved off this channel. The known, accepted cost is that + // a host on Node 22.5+ still cannot run Electron 34 or older (embedded Node + // 20.18.1), which dies exit 9 on this token. The alternatives were to gate the + // push on `coverage_active_for_cache` — already computed above, and it would + // spare every non-coverage run — or to move it to argv like the rest; both were + // declined in favour of keeping coverage reports clean unconditionally. So this + // is a settled trade, not an oversight: do NOT "fix" it silently. // NODE_OPTIONS is inherited by the whole subtree, and nub's set is matched to // the version of the Node it resolved, so // any descendant on an OLDER Node aborts at startup — Node rejects an unknown @@ -1321,19 +1287,11 @@ pub fn spawn_node(config: &SpawnConfig<'_>) -> Result { // `/node_modules/` segment) would remove the exclude, and with it this // trade. } - // Web Storage (mirrors the argv site above): always inject - // `--experimental-webstorage` into NODE_OPTIONS on the flag-needed band - // (22.4–24.x), regardless of any `--localstorage-file` opt-in, so a child - // `node` re-invocation inherits the flag and `sessionStorage` works out of - // the box. nub never synthesizes `--localstorage-file`. Same guard: only - // in-band, and not if the user already supplied/disabled the flag. - if flags::should_inject_experimental_webstorage( - &config.node.version, - config.user_args, - node_options.as_deref(), - ) { - node_opts_parts.push("--experimental-webstorage".to_string()); - } + // Web Storage is deliberately NOT pushed here. Its 22.4 floor is above nub's + // 18.19 support floor, so on this inherited channel it aborted any descendant + // older than 22.4 — it rides argv instead, at the site below. A child `node` + // still gets it: that child comes back through the PATH shim into this + // function, which applies it to that process's own argv. if let Some(existing) = existing_opts { // An INHERITED NODE_OPTIONS (ancestor nub or user-set) is appended // verbatim EXCEPT we first snip any version-gated flag whose floor @@ -1382,6 +1340,70 @@ pub fn spawn_node(config: &SpawnConfig<'_>) -> Result { // Argv reaches this process and nothing below it, which is the whole point. // Boolean flags are idempotent, so a merged duplicate is harmless. cmd.args(&inject_flags); + + // Web Storage rides argv from here for exactly the reason above, and it is the + // last flag to move: its 22.4 floor is ABOVE nub's 18.19 support floor, so while + // it sat on the inherited NODE_OPTIONS channel any descendant older than 22.4 + // aborted on it. That was reachable, not theoretical — a host on the 22.4–24 LTS + // band running Electron <= 34 (which embeds Node 20.18.1; Electron 28 embeds + // 18.18.2) hit it, and issue #7 was the same flag reaching an older child through + // a nested `.nvmrc`. `strip_unsupported_node_options` never covered it, because + // that is applied only to the INHERITED string, never to nub's own fresh tokens. + // + // It is injected here rather than through `compute_inject_flags`, so it sits + // OUTSIDE the Stage-4 accepted-flag intersection. Safe: its band is CLOSED + // (`22.4–<25`) and the flag stabilized (not removed) at 25 — no open-ended- + // removal hazard, so it needs no probe guard. (Any FUTURE open-ended flag should + // go through `compute_inject_flags` to inherit that guard, not this path.) + // + // nub ALWAYS injects it on the band where it is the enabling mechanism (Node + // 22.4 through <25, i.e. `webstorage_flag_needed`), regardless of whether the + // user opted into localStorage persistence (the maintainer, 2026-06-15: "a flag + // that we inject no matter what"). On that band `sessionStorage` needs ONLY the + // flag (no file) — gating it behind a `--localstorage-file` opt-in wrongly broke + // out-of-the-box sessionStorage. So inject unconditionally in-band; this makes + // sessionStorage work everywhere on 22.4–24 and installs the `localStorage` + // getter (which still throws `ERR_INVALID_ARG_VALUE` on ACCESS until the user + // supplies a `--localstorage-file`). Empirically the flag alone does NOT throw at + // startup on 22.4–24, so always-injecting is safe. + // + // nub NEVER synthesizes `--localstorage-file` — localStorage persistence stays + // the user's explicit opt-in (forwarded verbatim if they pass it). + // + // Below 22.4 the flag is an unrecognized "bad option" (would crash startup), and + // on 25+ Web Storage is native so the flag is unnecessary. Skip the inject when + // the user already supplied `--experimental-webstorage` / + // `--no-experimental-webstorage` (no double-add; respect an explicit disable — + // nub never re-enables over a user negation). + if flags::should_inject_experimental_webstorage( + &config.node.version, + config.user_args, + node_options.as_deref(), + ) { + cmd.arg("--experimental-webstorage"); + } + + // The localStorage neutralization signal moves WITH the flag, and must: on the + // band where nub injects `--experimental-webstorage` and the user did NOT supply + // `--localstorage-file`, the flag installs a `localStorage` getter that throws + // `ERR_INVALID_ARG_VALUE` on any access — even `typeof localStorage` throws. + // This tells nub's preload to delete that getter so the global is ABSENT, + // matching vanilla Node 24's shape, while `sessionStorage` keeps working. + // + // Pairing them at one site is what keeps the subtree correct now that the flag + // is per-process. The preload deliberately does NOT delete this var (see + // runtime/polyfills.cjs), so it still inherits downward — but inheritance alone + // can no longer be relied on to cover a descendant, because the flag that makes + // it necessary is now applied per spawn. Setting it wherever the flag is set is + // both sufficient and idempotent. The signal is an internal `__NUB_*` env var + // (brand-boundary-permitted plumbing). + if flags::should_neutralize_experimental_webstorage_localstorage( + &config.node.version, + config.user_args, + node_options.as_deref(), + ) { + cmd.env(flags::NEUTRALIZE_LOCALSTORAGE_ENV, "1"); + } } // `v8Flags` ride argv for the same structural reason as the block above — the @@ -2088,25 +2110,27 @@ pub fn compute_augmentation_env( // // It also carries `flags::node_options_safe_inject_flags` — today only // `--enable-source-maps`, which exists from Node 12.12 and so cannot abort any - // descendant in nub's supported range — plus `--experimental-webstorage` below. + // descendant in nub's supported range. Nothing else. // - // KNOWN RESIDUAL, not closed here. `--experimental-webstorage` is the one remaining - // version-gated token on this inherited channel, and its 22.4 floor IS above nub's - // 18.19 support floor, so a descendant below 22.4 aborts on it. It IS reachable: - // nub injects it whenever the HOST is in the 22.4-24 band, and Electron 34 embeds - // Node 20.18.1 while Electron 28 embeds 18.18.2 (Electron 35 is the first to clear - // 22.4, at 22.14.0) — so a host on the 22.4-24 LTS band running Electron <=34 hits - // exactly this. `strip_unsupported_node_options` does not save it either: that is - // applied only to the INHERITED string, never to nub's own freshly-pushed tokens. + // `--experimental-webstorage` was the last version-gated token on this inherited + // channel and is no longer on it. Its 22.4 floor is above nub's 18.19 support + // floor, so a descendant below 22.4 aborted on it, and that was reachable rather + // than theoretical: nub injects it whenever the HOST is in the 22.4-24 band, and + // Electron 34 embeds Node 20.18.1 while Electron 28 embeds 18.18.2 (Electron 35 is + // the first to clear 22.4, at 22.14.0) — so a host on the 22.4-24 LTS band running + // Electron <= 34 hit exactly this. `strip_unsupported_node_options` never covered + // it: that is applied only to the INHERITED string, never to nub's own freshly + // pushed tokens. Issue #7 was the same flag reaching an older child (a nested + // `.nvmrc` inside node_modules), and it was closed by the node_modules pin guard + // and `strip_unsupported_node_options` rather than by taking the flag off this + // channel; taking it off is what finally removed the class. // - // It is pre-existing rather than introduced here, and moving it is not a one-line - // change: its argv application and its paired localStorage-neutralization signal - // both live inside the augment block, so both would have to move out together and - // be re-verified across the 22.4-24 band — which is why it is not folded in here. - // This is not hypothetical: issue #7 was this same flag leaking to an older child - // (a nested `.nvmrc` inside node_modules), and it was closed by the node_modules - // pin guard and `strip_unsupported_node_options`, never by taking the flag off this - // channel. Do NOT read this channel as "safe for every descendant". + // The invariant to preserve: a token belongs here ONLY if its floor is at or below + // nub's 18.19 support floor. Anything version-gated goes on argv. This function + // now satisfies it with no exceptions — note that is STRICTER than `spawn_node`'s + // NODE_OPTIONS, which deliberately keeps `--test-coverage-exclude` (floor 22.5) + // because that token has to share a channel with the preload. Do not assume the + // two sets match. // // WHY NOT THE FEATURE FLAGS. `NODE_OPTIONS` is inherited by the ENTIRE process // subtree, and nub's flag set is matched to the version of the Node it resolved @@ -2151,20 +2175,18 @@ pub fn compute_augmentation_env( .iter() .map(|opt| node_options_token(opt)), ); - // Web Storage (mirrors `spawn_node`): always inject - // `--experimental-webstorage` on the flag-needed band (22.4–24.x) so a - // script-run child shell's `node` has `sessionStorage` out of the box, with no - // `--localstorage-file` opt-in required. nub never synthesizes - // `--localstorage-file`. (Scripts have no argv here — the only user channel is - // NODE_OPTIONS.) Guarded against double-add / a user - // `--no-experimental-webstorage` disable. - if flags::should_inject_experimental_webstorage( - &node_version, - &[], - existing_node_options.as_deref(), - ) { - node_opts_parts.push("--experimental-webstorage".to_string()); - } + // Web Storage is deliberately NOT pushed here (mirrors `spawn_node`). This env + // is inherited by the whole script subtree, and the flag's 22.4 floor is above + // nub's 18.19 support floor, so a descendant on an older Node aborted on it. A + // script's `node` still gets it: that invocation goes through the PATH shim into + // `spawn_node`, which puts it on that process's own argv. The cost is the same one + // the version-gated feature flags already pay — a script that launches Node by + // ABSOLUTE PATH bypasses the shim and so gets the preload without the flag. + // + // The neutralize signal below still IS set here. It is a plain `__NUB_*` env var + // that no Node parses as a flag, so it cannot abort anything at any version; it + // seeds the subtree for whichever descendants do receive the flag on argv. + // // localStorage-neutralize decision: compute BEFORE `existing_node_options` is // consumed below. Scripts have no argv here — the only user channel is // NODE_OPTIONS. Neutralize when nub injects the flag (flag-needed band, no user @@ -3898,9 +3920,11 @@ mod tests { #[test] fn script_spawn_policy_respects_quoted_node_options() { - // `compute_augmentation_env` has no argv channel: its script-shell - // Web Storage decision comes solely from inherited NODE_OPTIONS. Keep - // quoted user intent identical to the ordinary/direct spawn path. + // `compute_augmentation_env` has no argv channel, so its script-shell Web + // Storage decision reads inherited NODE_OPTIONS alone. It no longer PUSHES the + // flag (that moved to argv in `spawn_node`), but it still decides the + // localStorage neutralize signal the same way, and a user's quoted intent must + // land identically on both paths — which is what this pins. let version = NodeVersion::new(22, 15, 0); assert!(!flags::should_inject_experimental_webstorage( &version, diff --git a/wiki/design/architecture.md b/wiki/design/architecture.md index 9b92d48fd..51b881df9 100644 --- a/wiki/design/architecture.md +++ b/wiki/design/architecture.md @@ -32,13 +32,13 @@ All of it lives in one table, 47 features deep. Each carries sorted, non-overlap | Storage file | Passes a workspace-keyed `--localstorage-file` path, for Web Storage only | | Unflag on argv | Injects a V8 flag Node accepts only on the command line, never through `NODE_OPTIONS` | -Twelve distinct flags are injected this way, covering `node:sqlite`, EventSource, WebSocket, Web Storage, and the vm, wasm, addon and text-import module kinds. A thirteenth, `--js-defer-import-eval` for `import defer`, takes the argv-only route: Node rejects it in `NODE_OPTIONS`, so it can travel no other way. The polyfilled set is web and TC39 globals: Temporal, URLPattern, Worker, `navigator`, Float16Array, the disposable stack types, and the iterator, promise and collection helpers. +Twelve distinct flags are injected this way, covering `node:sqlite`, EventSource, WebSocket, Web Storage, and the vm, wasm, addon and text-import module kinds. A thirteenth, `--js-defer-import-eval` for `import defer`, differs only in why the other channel is closed to it: Node refuses that one in `NODE_OPTIONS` by name, while the rest are kept off it by Nub's own choice, for the reason below. The polyfilled set is web and TC39 globals: Temporal, URLPattern, Worker, `navigator`, Float16Array, the disposable stack types, and the iterator, promise and collection helpers. Below a feature's floor no band matches and Nub does nothing — the feature is unavailable rather than half-present. -Banding is exact because it has to be. Injecting an experimental flag on a version that does not have it is a hard startup abort, not a warning. Several rows carry two disjoint bands where a backport reached one release line and not another; `node:sqlite` is the clearest case, having been unflagged, re-flagged, and unflagged again. ShadowRealm is never injected at all, because the flag crashes embedded Node through a snapshot hash mismatch. That hazard is what separates the two unflag shapes: a flag in `NODE_OPTIONS` is inherited by every process below, including an embedded Node booting from a V8 snapshot, while an argv flag reaches only the process Nub spawns. +Banding is exact because it has to be. Injecting an experimental flag on a version that does not have it is a hard startup abort, not a warning. Several rows carry two disjoint bands where a backport reached one release line and not another; `node:sqlite` is the clearest case, having been unflagged, re-flagged, and unflagged again. ShadowRealm is never injected at all, because the flag crashes embedded Node through a snapshot hash mismatch. That hazard generalizes, and it is why no version-gated flag rides `NODE_OPTIONS`. That string is inherited by every process below, including an embedded Node booting from a V8 snapshot, and the set Nub builds is matched to the version of the Node it resolved — so a descendant running an older Node meets a flag it cannot parse and aborts at startup. Every injected flag travels on argv instead, reaching the process Nub spawns and nothing beneath it; a child `node` gets its own copy by re-entering Nub through the PATH shim. What stays in `NODE_OPTIONS` is the preload, which every Node accepts, and flags whose floor sits at or below Nub's own support floor. -The flag-injection logic in [[crates/nub-core/src/node/flags.rs#compute_inject_flags]] reads the table in [[crates/nub-core/src/node/feature_matrix.rs#FEATURES]] rather than keeping its own copy, so a version-gated claim traces to a row. A band that runs to infinity would eventually inject a flag the running Node has dropped, so both unflag shapes are checked against the real binary before injection: the `NODE_OPTIONS` set against what Node reports as accepted there, and the argv set by spawning the binary with the flag once and caching the verdict. A flag the binary no longer takes is dropped rather than aborting it at startup. Surveys behind the bands: [[research/node-experimental-flag-lifecycle]], [[research/node-flag-arity]]. +The flag-injection logic in [[crates/nub-core/src/node/flags.rs#compute_inject_flags]] reads the table in [[crates/nub-core/src/node/feature_matrix.rs#FEATURES]] rather than keeping its own copy, so a version-gated claim traces to a row. A band that runs to infinity would eventually inject a flag the running Node has dropped, so both unflag shapes are checked against the real binary before injection, by different probes. The ordinary set is filtered against the list Node reports as accepted in `NODE_OPTIONS` — a cheap read that stays a valid existence check even though the flags themselves travel on argv. The argv-only set cannot use that list, since a flag Node refuses there is absent from it by construction, so each is checked by spawning the binary with it once and caching the verdict. A flag the binary no longer takes is dropped rather than aborting it at startup. Surveys behind the bands: [[research/node-experimental-flag-lifecycle]], [[research/node-flag-arity]]. ## Two tiers