diff --git a/changelog.d/11636-domain-live-namespace-reads.md b/changelog.d/11636-domain-live-namespace-reads.md new file mode 100644 index 0000000000..36093f688c --- /dev/null +++ b/changelog.d/11636-domain-live-namespace-reads.md @@ -0,0 +1,38 @@ +Fixed `domain.active`/`domain._stack` freezing at their first-read value on +the default `node:domain` import (`import domain from "node:domain"`): +`d.enter(); domain.active; d.exit(); domain.active` kept returning the +entered domain instead of `undefined`, and `domain._stack.length` stayed at +1. `enter()`/`exit()` themselves were correct — only the READ was stale. + +Root cause: aaa3ba534 ("fix(module): complete Node 26 parity for node:module +(#7312)") added an ESM default/named export snapshot cache +(`NATIVE_ESM_EXPORT_VALUES` in `native_module_export_value`, +`crates/perry-runtime/src/object/native_module.rs`) that memoizes the first +non-`undefined` value read for a native-module property, refreshed only by +`module.syncBuiltinESMExports()`. `domain.active`/`domain._stack` are not +constants — the `"domain"` arm of `get_native_module_constant` +(`crates/perry-runtime/src/object/native_module/constants.rs`) resolves them +through a dispatch call (`JS_NATIVE_DOMAIN_DISPATCH`) on every read — so the +snapshot cache froze them at whatever value the dispatch call happened to +return on first access. + +Fix: `native_module_constant_is_live(module, property)` marks +`("domain", "_stack")`/`("domain", "active")` as live, dispatch-backed reads; +`native_module_export_value` now skips both the cache lookup and the cache +write for a live property, so it is re-evaluated on every read while genuine +constants keep the snapshot-cache fast path. `require("domain")` and +`process.domain` already read through a separate, uncached dynamic-field +path and were unaffected; `import * as domain from "node:domain"` shares +this same live-read fix but Node itself freezes `.active` on that import +form at its initial value until `syncBuiltinESMExports()` (a pre-existing, +separate ESM/CJS interop gap, out of scope here — not covered by any +node-suite test). + +Validation: node-suite `domain` 13/48 → 18/48 (fast mode, +`PERRY_NO_AUTO_OPTIMIZE=1`), recovering exactly the 5 regressed tests +(`bind-call-contract`, `enter-duplicate`, `enter-exit-active`, +`intercept-call-contract`, `run-nested-restore`); `module`/`process`/`events`/ +`globals`/`util` unchanged. New regression test: +`test-files/test_gap_domain_active_stack_live_reads.ts` (fails before the +fix, byte-for-byte matches `node --experimental-strip-types` after). +`cargo test -p perry-runtime native_module`: 37/37 passing. diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index a544a6a808..78547a7e01 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -53,7 +53,7 @@ pub(crate) use callable_exports::{ tls_constructor_prototype_is_instance_of, util_inspect_default_options_value, zlib_codes_object, }; -pub(crate) use constants::get_native_module_constant; +pub(crate) use constants::{get_native_module_constant, native_module_constant_is_live}; pub(crate) use constructor_exports::{ bound_native_callable_is_constructor_value, is_native_module_constructor_export, }; @@ -1054,8 +1054,12 @@ fn native_module_export_value(module: f64, property: f64, observe_namespace_writ return value; } } + let live = native_module_constant_is_live(&module, &property); let key = format!("{module}\0{property}"); - if let Some(bits) = NATIVE_ESM_EXPORT_VALUES.with(|values| values.borrow().get(&key).copied()) { + let cached = (!live) + .then(|| NATIVE_ESM_EXPORT_VALUES.with(|v| v.borrow().get(&key).copied())) + .flatten(); + if let Some(bits) = cached { return f64::from_bits(bits); } let value = unsafe { @@ -1067,7 +1071,7 @@ fn native_module_export_value(module: f64, property: f64, observe_namespace_writ false, ) }; - if value.to_bits() == crate::value::TAG_UNDEFINED { + if live || value.to_bits() == crate::value::TAG_UNDEFINED { return value; } NATIVE_ESM_EXPORT_VALUES.with(|values| { diff --git a/crates/perry-runtime/src/object/native_module/constants.rs b/crates/perry-runtime/src/object/native_module/constants.rs index dd9295dcea..73ee175b67 100644 --- a/crates/perry-runtime/src/object/native_module/constants.rs +++ b/crates/perry-runtime/src/object/native_module/constants.rs @@ -943,6 +943,15 @@ pub(crate) unsafe fn get_native_module_constant( } } +/// Live properties answered by a dispatch call above: the ESM export snapshot +/// cache (`NATIVE_ESM_EXPORT_VALUES`) must never memoize them. +pub(crate) fn native_module_constant_is_live(module_name: &str, property: &str) -> bool { + matches!( + (module_name, property), + ("domain", "_stack") | ("domain", "active") + ) +} + /// Sorted table for `fs_const_tail`: block-level `#[cfg]` predicates mirrored /// verbatim as per-platform tables; per-arm cfg chains composed with /// `not(any(..))` so first-match-wins semantics survive; every value kept diff --git a/test-files/test_gap_domain_active_stack_live_reads.ts b/test-files/test_gap_domain_active_stack_live_reads.ts new file mode 100644 index 0000000000..7b590c9131 --- /dev/null +++ b/test-files/test_gap_domain_active_stack_live_reads.ts @@ -0,0 +1,25 @@ +// domain.active / domain._stack must be re-read after exit(), not frozen at the first read. +import domain from "node:domain"; + +const d = domain.create(); +console.log("before enter — active:", String(domain.active), "stack len:", domain._stack.length); + +d.enter(); +console.log( + "while entered — active === d:", + domain.active === d, + "stack len:", + domain._stack.length +); + +const observedWhileEntered = domain.active; + +d.exit(); +console.log( + "after exit — active:", + String(domain.active), + "stack len:", + domain._stack.length, + "observed-while-entered was live:", + observedWhileEntered === d +);