Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions changelog.d/11636-domain-live-namespace-reads.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 7 additions & 3 deletions crates/perry-runtime/src/object/native_module.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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 {
Expand All @@ -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| {
Expand Down
9 changes: 9 additions & 0 deletions crates/perry-runtime/src/object/native_module/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions test-files/test_gap_domain_active_stack_live_reads.ts
Original file line number Diff line number Diff line change
@@ -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
);
Loading