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(); 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) {