Skip to content
Open
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
39 changes: 35 additions & 4 deletions crates/nub-cli/tests/node_compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}")),
};
Expand All @@ -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;
}
Expand All @@ -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;
Expand All @@ -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();
Expand Down
21 changes: 19 additions & 2 deletions runtime/polyfills.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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) {
Expand Down
Loading