From 337226b2a6d575ddc6413206e39d39cdce7cc2eb Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:38:36 -0700 Subject: [PATCH 1/2] fix(runtime): stop materializing undici at preload time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `globalThis.MessageEvent` and `globalThis.File` are lazy undici-backed globals. The preload probed them with `typeof`, and that value read synchronously materializes undici plus its whole http/http2/tls/crypto/zlib/worker closure — process.moduleLoadList went to 227 entries where plain node has 110, on every nub startup. Probe with `in` instead, which sees the lazy property without firing its getter, and version-gate the MessageEvent.ports freeze: Node freezes `.ports` natively from 22.3.0, so the wrap is a no-op across the entire fast tier (floor 22.15). Only the pre-22.3 compat tier still installs it, where the load is the accepted cost of the correctness it buys. The block directly above already documented this exact trap for Temporal ("Touching globalThis.Temporal here would defeat that laziness"); MessageEvent was missed. Measured: moduleLoadList 227 -> 114 (node: 110), undici absent at startup, and `.ports` still frozen on delivery. Also removes the startup cost that made several profiler-sensitive Node compat tests exceed their harness budget, and the per-Worker accumulation behind a 5.5x RSS growth over 60 workers. --- runtime/polyfills.cjs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/runtime/polyfills.cjs b/runtime/polyfills.cjs index a633f7184..440168c7a 100644 --- a/runtime/polyfills.cjs +++ b/runtime/polyfills.cjs @@ -105,7 +105,12 @@ function installSyncPolyfills(preloaded) { // force one throwaway construction INSIDE a suppression window: that consumes // Node's once-per-feature guard (the warning is dropped here) so the user's later // `new File(...)` is silent. - if (typeof globalThis.File === "undefined" || typeof globalThis.Blob === "undefined") { + // Probe with `in`, not a value read: `File`/`Blob` are lazy undici-backed globals + // on the modern tier, so `typeof globalThis.File` would materialize undici at + // preload time (see the MessageEvent note below for the full cost). `in` sees the + // lazy property without firing its getter, so the backfill still runs only on the + // floor (where the globals are genuinely absent) with no startup penalty above it. + if (!("File" in globalThis) || !("Blob" in globalThis)) { const origEmitWarning = process.emitWarning; process.emitWarning = function (warning, ...rest) { const opt = rest[0]; @@ -142,7 +147,19 @@ function installSyncPolyfills(preloaded) { // getter so every read yields a frozen array, for both a native MessageChannel's // delivery and nub's worker-side MessageEvents. Idempotent (the wrapper is marked // so a re-run in the same realm doesn't double-wrap). - if (typeof globalThis.MessageEvent === "function") { + // + // STARTUP-COST INVARIANT (do not regress): `globalThis.MessageEvent` is a lazy + // undici-backed global — READING its value (`typeof`, `.prototype`, or the value + // itself) synchronously materializes undici and its whole http/http2/tls/crypto/ + // zlib closure (~112 builtins, ~40ms CPU) at preload time, on every nub startup. + // So (a) probe existence with `in`, which never fires the lazy getter, and + // (b) version-gate: Node freezes `MessageEvent.ports` natively from 22.3.0, so + // the wrap is a pure no-op on the entire fast tier (floor 22.15) — skip it there + // and never touch the global. Only the pre-22.3 compat tier still needs the + // wrap, and materializing undici there (legacy minority) is the accepted cost. + const [__nodeMajor, __nodeMinor] = process.versions.node.split(".").map(Number); + const __portsFrozenNatively = __nodeMajor > 22 || (__nodeMajor === 22 && __nodeMinor >= 3); + if (!__portsFrozenNatively && "MessageEvent" in globalThis) { const proto = globalThis.MessageEvent.prototype; const desc = Object.getOwnPropertyDescriptor(proto, "ports"); if (desc && typeof desc.get === "function" && desc.configurable && !desc.get.__nubFreezesPorts) { From 97379deb5988a4e170f6cadddbb37d6f51d11557 Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:38:36 -0700 Subject: [PATCH 2/2] test(node-compat): reap the child's process group on exit and timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness killed only the leader on timeout. Node compat tests routinely fork servers, workers and `spawn(process.execPath)` grandchildren, which were then orphaned to PID 1 and kept running; across ~2,554 tests at up to 16-way parallelism they accumulated until the CI runner died. That is why the compat corpus job was removed from CI (2026-06-03) and never restored, leaving the suite with no automated signal. Spawn each test as its own process-group leader and SIGKILL the negative pgid, at both the timeout path and the clean-exit path — a leader can exit zero while leaving a detached server behind. `tests/cross-runtime/run.mjs` already solved this the same way; this ports it to the Rust harness. Verified against a leaking fixture: a non-detached child left running by the test is reaped by the group kill. A `detached: true` grandchild calls setsid and escapes any pgid reap by construction; that is inherent and out of scope. --- crates/nub-cli/tests/node_compat.rs | 39 ++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/nub-cli/tests/node_compat.rs b/crates/nub-cli/tests/node_compat.rs index d16d9dff3..3efa40fb4 100644 --- a/crates/nub-cli/tests/node_compat.rs +++ b/crates/nub-cli/tests/node_compat.rs @@ -45,17 +45,29 @@ fn run_with_timeout( tmp: &Path, fork_id: usize, ) -> RunOutcome { - let mut child = match Command::new(nub) - .arg(test_path) + let mut cmd = Command::new(nub); + cmd.arg(test_path) .current_dir(cwd) .env("NODE_TEST_KNOWN_GLOBALS", "0") .env("TMPDIR", tmp) .env("NODE_TEST_FORK_ID", fork_id.to_string()) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() + .stderr(Stdio::piped()); + // Make the child its OWN process-group leader so we can reap its whole subtree + // by signalling the negative pgid. Node compat tests routinely fork servers, + // workers, and `spawn(process.execPath)` grandchildren; killing only the leader + // (the old `child.kill()`) orphaned those to PID 1, where they kept running and + // spinning CPU. Across ~2,554 tests at up to 16-way parallelism the orphans + // accumulated until the runner died — the reason the compat corpus was pulled + // from CI (2026-06-03) and never restored. `run.mjs` already solved this exact + // failure the same way (detached spawn + group kill); this ports it. + #[cfg(unix)] { + use std::os::unix::process::CommandExt as _; + cmd.process_group(0); + } + let mut child = match cmd.spawn() { Ok(c) => c, Err(e) => return RunOutcome::Failed(format!("spawn error: {e}")), }; @@ -68,6 +80,9 @@ fn run_with_timeout( if let Some(mut s) = child.stderr.take() { let _ = s.read_to_string(&mut stderr); } + // Reap grandchildren the test left running even though its leader + // exited cleanly (a detached server/worker outliving the test body). + reap_group(&child); if status.success() { return RunOutcome::Passed; } @@ -83,6 +98,7 @@ fn run_with_timeout( } Ok(None) => { if start.elapsed() >= PER_TEST_TIMEOUT { + reap_group(&child); let _ = child.kill(); let _ = child.wait(); return RunOutcome::TimedOut; @@ -94,6 +110,21 @@ fn run_with_timeout( } } +/// SIGKILL the child's entire process group, reaping any servers/workers/ +/// grandchildren it spawned. The child leads its own group (`process_group(0)` at +/// spawn), so the negative pgid targets only this test's subtree — never the test +/// runner. A no-op on non-unix, where each test's `child.kill()` is the fallback. +fn reap_group(child: &std::process::Child) { + #[cfg(unix)] + // SAFETY: signalling a negative pgid is async-signal-safe; the pgid equals the + // child's pid (it is its own group leader), so this cannot reach the runner. + unsafe { + libc::kill(-(child.id() as i32), libc::SIGKILL); + } + #[cfg(not(unix))] + let _ = child; +} + fn nub_binary() -> PathBuf { let mut path = std::env::current_exe().unwrap(); path.pop();