diff --git a/changelog.d/10749-require-main-entry-only.md b/changelog.d/10749-require-main-entry-only.md new file mode 100644 index 0000000000..7c7cb43655 --- /dev/null +++ b/changelog.d/10749-require-main-entry-only.md @@ -0,0 +1,32 @@ +Fixed `require.main === module` being trivially `true` in **every** compiled +CommonJS module, not just the process entry point (#10735). +`cjs_wrap`'s preamble unconditionally emitted `require.main = module;`, so +the standard "am I the entry, or merely imported?" idiom took its CLI +branch in every dependency that used it — including bundled packages +(dotenv 18.0.1's `dist/index.cjs` is a confirmed real-world example). + +The fix threads the compiler's existing entry-module knowledge (the same +comparison `import.meta.main` uses) through `cjs_wrap`, but that alone is +insufficient: `cjs_wrap` transpiles a statically-known +`require('./relative')` into a hoisted ESM import, and ESM import +evaluation runs a module's static-import dependencies *before* the +importing module's own top-level code. So a CJS entry's own dependencies +initialize before the entry's own preamble runs, which means "the entry +publishes `require.main` in its own preamble" is too late for any +dependency reached via a hoisted static import. Fixed by publishing a +placeholder object as the shared "main module" from the program's `main()` +itself, before any module's `__init` runs at all (gated on the entry being +CJS-wrapped, so an ESM entry correctly leaves `require.main` `undefined` +for CJS modules it imports); the entry later reclaims that exact object +and fills in its real fields, preserving identity for dependencies that +captured `require.main` before the entry's own code ran. + +The new runtime-side cache backing this (`CJS_MAIN_MODULE`) is registered +with the GC's mutable-root-scanner machinery and verified under forced +evacuation (`PERRY_GC_SCHEDULE_SEED`/`PERRY_GC_FORCE_EVACUATE`/ +`PERRY_GC_PROTECT_FROMSPACE`): the placeholder moved, the cache followed +it, and every identity assertion held across 8,006 forced collections. A +new test (`gc::tests::cjs_main_module`) asserts the rewrite counter is +non-zero under a real evacuating minor, so a future regression that stops +rewriting the cache fails a test instead of silently reintroducing this +bug's failure mode. diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 484c6d0176..17e6f1d24b 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -749,6 +749,14 @@ pub(super) fn compile_module_entry( ], ); } + // #10735: publish the shared `require.main` placeholder before + // ANY module's `__init` below runs (those are this CJS entry's + // OWN static imports, which ESM eval order runs before the + // entry's own preamble). See the callee's doc comment. Skipped + // for an ESM entry, which must leave `require.main` `undefined`. + if crate::collectors::is_cjs_wrapped_module(hir) { + blk.call_void("js_bootstrap_cjs_main_module_placeholder", &[]); + } for (index, prefix) in non_entry_module_prefixes.iter().enumerate() { if cross_module.deferred_module_prefixes.contains(prefix) { continue; diff --git a/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs b/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs index 5fa5d09881..4c2b89861c 100644 --- a/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs +++ b/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs @@ -173,6 +173,23 @@ .block() .call(DOUBLE, "js_has_path_module", &[(DOUBLE, &path)])); } + // #10735: publish/read the shared CJS "main module" — + // `require.main` for the process entry module vs. for every + // other CJS-wrapped module. See `cjs_wrap/wrap.rs` and + // `perry-runtime::module_require::{js_set_cjs_main_module, + // js_get_cjs_main_module}`. + "setCjsMainModule" => { + let module = args.first().map_or_else( + || Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))), + |arg| lower_expr(ctx, arg), + )?; + ctx.block() + .call_void("js_set_cjs_main_module", &[(DOUBLE, &module)]); + return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); + } + "getCjsMainModule" => { + return Ok(ctx.block().call(DOUBLE, "js_get_cjs_main_module", &[])); + } // #10360: seeded into every module init under `--platform bun` // so the runtime can follow Bun where its Web APIs differ from // Node's (e.g. the Response null-body-status check). diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index e8f1bfd637..d1b723a377 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -409,6 +409,15 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { module.declare_function("js_has_path_module", DOUBLE, &[DOUBLE]); // #10360: `--platform bun` marker (see `__perry_runtime.setBunPlatform`). module.declare_function("js_set_bun_platform", VOID, &[]); + // #10735: shared CJS "main module" (`require.main`) — the entry module + // publishes, every other CJS module reads back. See + // `__perry_runtime.setCjsMainModule` / `.getCjsMainModule`. + module.declare_function("js_set_cjs_main_module", VOID, &[DOUBLE]); + module.declare_function("js_get_cjs_main_module", DOUBLE, &[]); + // #10735: allocates and publishes the placeholder `require.main` object + // BEFORE any module's `__init` runs (called directly from `main()`, not + // from generated module JS — see `codegen::entry::compile_module_entry`). + module.declare_function("js_bootstrap_cjs_main_module_placeholder", VOID, &[]); // Next.js wall 54 (part 2): register a Deferred module's `__init` address by // path so a runtime `require(absolutePath)` can trigger its lazy init. module.declare_function("js_register_path_init", VOID, &[PTR, I64, I64]); diff --git a/crates/perry-hir/src/lower/expr_call/globals.rs b/crates/perry-hir/src/lower/expr_call/globals.rs index 44f4596f55..2551653daa 100644 --- a/crates/perry-hir/src/lower/expr_call/globals.rs +++ b/crates/perry-hir/src/lower/expr_call/globals.rs @@ -351,6 +351,38 @@ pub(super) fn try_global_builtins( args: vec![path], })); } + // #10735: `require.main` must be the process ENTRY module only — + // equal to `module` there, unequal (or `undefined`, when the + // process entry is ESM) everywhere else. The CJS preamble + // (`cjs_wrap/wrap.rs`) emits exactly one of these two calls per + // module depending on whether IT is the compile-time entry: + // the entry publishes its own `module` record as the shared + // "main module"; every other CJS module reads it back instead of + // assigning its OWN `module` (which is the bug — it made + // `require.main === module` trivially true everywhere). + "__perry_set_cjs_main_module" => { + let module = if !args.is_empty() { + args.remove(0) + } else { + Expr::Undefined + }; + return Ok(Ok(Expr::NativeMethodCall { + module: "__perry_runtime".to_string(), + class_name: None, + object: None, + method: "setCjsMainModule".to_string(), + args: vec![module], + })); + } + "__perry_get_cjs_main_module" => { + return Ok(Ok(Expr::NativeMethodCall { + module: "__perry_runtime".to_string(), + class_name: None, + object: None, + method: "getCjsMainModule".to_string(), + args: vec![], + })); + } "Symbol" => { // Symbol() / Symbol(description) if args.is_empty() { diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index c062e634f4..ae21b86be8 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -999,6 +999,11 @@ pub fn gc_init() { // Runtime path-module exports and cached initialization errors live in a // per-heap Rust registry, so moving GC must mark and rewrite them. reg_scanner!(crate::module_require::scan_module_path_roots_mut); + // #10735: the shared CJS "main module" (`require.main` / Node's + // `process.mainModule`) is a raw heap pointer cached in a thread-local + // outside any shadow frame — a moving collection must mark and rewrite + // it like any other mutable root. + reg_scanner!(crate::module_require::scan_cjs_main_module_root_mut); reg_budgeted_scanner!( promise_mutable_root_scanner, crate::promise::scan_promise_roots_mut_step, diff --git a/crates/perry-runtime/src/gc/tests/cjs_main_module.rs b/crates/perry-runtime/src/gc/tests/cjs_main_module.rs new file mode 100644 index 0000000000..b6fd7269d2 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/cjs_main_module.rs @@ -0,0 +1,58 @@ +//! #10735 witness: `scan_cjs_main_module_root_mut`'s rewrite counter is a +//! diagnostic nothing else reads, which is exactly the kind of thing that +//! rots silently -- a future change that stops rewriting the cache would +//! report `total_rewrites=0` forever while looking perfectly healthy. This +//! test forces a REAL evacuating collection (not a passive scan that never +//! sees a move) and asserts both that the placeholder's address actually +//! changed and that the counter tracked it, per CLAUDE.md's "a gate must +//! assert its subject was live" rule. +//! +//! Lives here rather than in `module_require.rs` because a real evacuating +//! minor needs `CopyingNurseryTestGuard`'s preflight setup (generated write +//! barriers reporting "active", the conservative-full-scan test default +//! turned off, a clean shadow stack / remembered set) -- machinery private +//! to this `gc::tests` tree. That guard also clears the thread's mutable +//! scanner registry so unrelated GC tests see only the roots they install, +//! which would remove the very scanner under test, so this file +//! re-registers it explicitly after constructing the guard. + +use super::super::*; +use super::support::CopyingNurseryTestGuard; + +#[test] +fn placeholder_move_under_forced_evacuation_increments_the_rewrite_counter() { + let _nursery = CopyingNurseryTestGuard::new(0); + let _evac = knob_overrides::ForcedEvacuationTestGuard::on(); + let _diag = GcDiagTestGuard::force_on(); + // `CopyingNurseryTestGuard::new` clears the thread's scanner registry so + // this collection sees exactly the roots the test installs -- put the + // one under test back. + gc_register_mutable_root_scanner(crate::module_require::scan_cjs_main_module_root_mut); + + crate::module_require::js_bootstrap_cjs_main_module_placeholder(); + let before_bits = + crate::module_require::test_cjs_main_module_bits().expect("placeholder must be published"); + let rewrites_before = crate::module_require::test_cjs_main_module_rewrite_count(); + + js_gc_collect(); + + let after_bits = crate::module_require::test_cjs_main_module_bits() + .expect("placeholder must survive the collection"); + let rewrites_after = crate::module_require::test_cjs_main_module_rewrite_count(); + + assert_ne!( + before_bits, after_bits, + "forced evacuation must have moved the placeholder -- if the \ + address is unchanged, this test's premise (a real move happened) \ + is false, and the counter assertion below would be checking \ + nothing (before={before_bits:#x} after={after_bits:#x})" + ); + assert!( + rewrites_after > rewrites_before, + "scan_cjs_main_module_root_mut must have counted the rewrite \ + (before={rewrites_before} after={rewrites_after}); a zero delta \ + here means the cache stopped following the object it caches -- \ + the exact regression #10735's identity guarantee depends on never \ + happening" + ); +} diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index 63d50b4fa6..12c8c0fa0b 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -15,6 +15,7 @@ mod buffer_side_tables; mod census; mod census_block_windows; mod census_whole_block; +mod cjs_main_module; mod concat_site; mod contract; mod copy_slot_decode; diff --git a/crates/perry-runtime/src/module_require.rs b/crates/perry-runtime/src/module_require.rs index 17d92d2018..01c35ad3d6 100644 --- a/crates/perry-runtime/src/module_require.rs +++ b/crates/perry-runtime/src/module_require.rs @@ -23,6 +23,158 @@ fn undefined() -> f64 { f64::from_bits(TAG_UNDEFINED) } +crate::perry_thread_local! { + /// The process ENTRY module's own CJS `module` record — Node's + /// `require.main` / `process.mainModule`. Set exactly once, by the entry + /// module's own preamble (see `cjs_wrap::wrap_commonjs_with_body_offset` + /// in the compiler), before that module's body runs any `require()` of + /// its own — so every dependency it (transitively) requires observes + /// this already published. Every OTHER CJS-wrapped module reads it back + /// via [`js_get_cjs_main_module`] instead of assigning its own local + /// `module`, which is the #10735 bug this replaces (`require.main === + /// module` was trivially true in every compiled CommonJS module, not + /// just the real entry point). + /// + /// Stays `None` (JS `undefined`) for the lifetime of the heap when the + /// process entry is ESM — matching Node, where a CJS module reached only + /// via `import` from an ESM entry has `require.main === undefined` + /// (there is no CommonJS "main" in that process). + static CJS_MAIN_MODULE: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +/// Codegen FFI: publish the process entry module's own CJS `module` record as +/// the shared "main module". Emitted ONCE, in the entry module's preamble, +/// before its body runs any `require()` of a dependency — so every later read +/// on this heap sees it already set. First call wins (idempotent): there +/// should never be a second, but a re-entrant load must not let a later +/// module overwrite the true entry. +#[no_mangle] +pub extern "C" fn js_set_cjs_main_module(module_value: f64) { + CJS_MAIN_MODULE.with(|slot| { + let mut slot = slot.borrow_mut(); + if slot.is_none() { + *slot = Some(module_value.to_bits()); + } + }); +} + +/// Codegen FFI: `require.main` for a NON-entry CJS module — the value +/// [`js_set_cjs_main_module`] published, or JS `undefined` if this heap's +/// process entry never called it (an ESM entry, or a heap whose entry point +/// was never CommonJS-wrapped). +#[no_mangle] +pub extern "C" fn js_get_cjs_main_module() -> f64 { + CJS_MAIN_MODULE.with(|slot| slot.borrow().map(f64::from_bits).unwrap_or_else(undefined)) +} + +/// Codegen FFI: emitted ONCE, in `main()`, before ANY module's `__init` runs +/// — including the entry's own, since ESM's static-import evaluation order +/// runs every hoisted dependency's top-level code before the importing +/// module's (see `cjs_wrap`'s `require('./x')` -> `import` hoist). Node's +/// loader sets up `require.main`/`process.mainModule` before invoking the +/// entry's script body at all, so no CommonJS module - not even one loaded +/// before the entry's own preamble textually runs - ever observes it unset. +/// Perry can't replicate that ordering directly (the entry's real `module` +/// record is built by JS the entry's own preamble emits, which necessarily +/// runs LAST among a CJS entry's static imports), so this allocates a bare +/// placeholder object and publishes THAT via [`js_set_cjs_main_module`] +/// up front. Every non-entry module's `require.main` resolves to this same +/// object from heap birth. When the entry's own preamble finally runs, it +/// reclaims this exact object (via [`js_get_cjs_main_module`]) and fills in +/// its real fields in place — same identity throughout, so a dependency that +/// captured `require.main` before the entry ran still `===`-matches the +/// entry's `module` afterward (JS identity survives mutation). +/// +/// Codegen emits the call only when the entry module is itself CJS-wrapped +/// (`collectors::is_cjs_wrapped_module`); an ESM entry never calls this, so +/// [`js_get_cjs_main_module`] stays `undefined` for the lifetime of the heap +/// — matching Node, where a CJS module reached only via `import` from an ESM +/// entry has no CommonJS "main" at all. +#[no_mangle] +pub extern "C" fn js_bootstrap_cjs_main_module_placeholder() { + let placeholder = object_value(js_object_alloc(0, 0)); + js_set_cjs_main_module(placeholder); +} + +/// GC root scanner for [`CJS_MAIN_MODULE`]: a raw heap pointer cached outside +/// any shadow frame, so a moving collection must mark and rewrite it like any +/// other mutable root. Registered in `gc::gc_init` beside +/// `scan_module_path_roots_mut`. +pub fn scan_cjs_main_module_root_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + CJS_MAIN_MODULE.with(|slot| { + if let Some(bits) = slot.borrow_mut().as_mut() { + let rewritten = visitor.visit_nanbox_u64_slot(bits); + // #10735: `visit_nanbox_u64_slot` returns true only when it + // actually rewrote the slot (the cached object moved this + // cycle). A diagnostic-only counter distinguishing "the + // placeholder happened never to move" from "it moved and the + // cache followed it" -- the load-bearing question for this + // holder's identity guarantee, not visible from the generic + // per-cycle counters. + if rewritten && crate::gc::gc_diag_enabled() { + CJS_MAIN_MODULE_REWRITES.with(|c| c.set(c.get() + 1)); + eprintln!( + "[cjs-main-module] rewritten: new_bits={:#018x} total_rewrites={}", + *bits, + CJS_MAIN_MODULE_REWRITES.with(std::cell::Cell::get) + ); + } + } + }); +} + +crate::perry_thread_local! { + /// Diagnostic-only (`PERRY_GC_DIAG=1`) count of how many times + /// [`scan_cjs_main_module_root_mut`] actually rewrote the cached + /// placeholder's bits (i.e. the object moved while cached). Never + /// read for behaviour. + static CJS_MAIN_MODULE_REWRITES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +mod cjs_main_module_tests { + use super::*; + + // Each `#[test]` fn runs on its own harness-spawned thread, so + // `CJS_MAIN_MODULE` (thread-local) starts fresh here regardless of test + // execution order — no explicit reset needed. + + #[test] + fn defaults_to_undefined_when_no_entry_has_published() { + assert_eq!(js_get_cjs_main_module().to_bits(), TAG_UNDEFINED); + } + + #[test] + fn published_value_reads_back_identically() { + let entry_module = string_value("entry-module-marker"); + js_set_cjs_main_module(entry_module); + assert_eq!(js_get_cjs_main_module().to_bits(), entry_module.to_bits()); + } + + #[test] + fn first_publication_wins_a_later_call_cannot_overwrite_it() { + let first = string_value("first-entry"); + let second = string_value("second-entry-should-be-ignored"); + js_set_cjs_main_module(first); + js_set_cjs_main_module(second); + assert_eq!(js_get_cjs_main_module().to_bits(), first.to_bits()); + } +} + +/// Test-only accessors used from `gc::tests::cjs_main_module` (a sibling +/// module tree that cannot see `cjs_main_module_tests`'s items, and needs +/// its own file to reach the `CopyingNurseryTestGuard` apparatus a real +/// forced-evacuation witness test requires -- see that file for why). +#[cfg(test)] +pub(crate) fn test_cjs_main_module_bits() -> Option { + CJS_MAIN_MODULE.with(|s| *s.borrow()) +} + +#[cfg(test)] +pub(crate) fn test_cjs_main_module_rewrite_count() -> u64 { + CJS_MAIN_MODULE_REWRITES.with(std::cell::Cell::get) +} + fn null() -> f64 { f64::from_bits(TAG_NULL) } diff --git a/crates/perry/src/commands/compile/cjs_wrap/parcel_watcher_tests.rs b/crates/perry/src/commands/compile/cjs_wrap/parcel_watcher_tests.rs index 7c86633760..5fb96888a8 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/parcel_watcher_tests.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/parcel_watcher_tests.rs @@ -14,6 +14,7 @@ module.exports = binding src, &PathBuf::from("/tmp/node_modules/opencode/watcher.js"), Some("linux-x86_64-musl"), + false, ); assert!( wrapped.contains("from '@parcel/watcher-linux-x64-musl'") diff --git a/crates/perry/src/commands/compile/cjs_wrap/preamble_canary_tests.rs b/crates/perry/src/commands/compile/cjs_wrap/preamble_canary_tests.rs index 5f5b6a780d..d8e3b7d87e 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/preamble_canary_tests.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/preamble_canary_tests.rs @@ -50,7 +50,7 @@ exports.compute = compute; fn wrap_and_lower(body: &str) -> perry_hir::Module { let path = Path::new("/tmp/perry-canary/node_modules/dep/index.js"); - let wrapped = wrap_commonjs_for_target(body, path, None); + let wrapped = wrap_commonjs_for_target(body, path, None, false); let ast = perry_parser::parse_typescript(&wrapped, "index.js") .expect("the wrap template must produce parseable ESM"); perry_hir::lower_module(&ast, "dep", &path.to_string_lossy()) @@ -63,7 +63,7 @@ fn wrap_and_lower(body: &str) -> perry_hir::Module { #[test] fn cjs_preamble_does_not_arm_the_ptr_shape_module_barrier() { let path = Path::new("/tmp/perry-canary/node_modules/dep/index.js"); - let wrapped = wrap_commonjs_for_target(CJS_FIXTURE, path, None); + let wrapped = wrap_commonjs_for_target(CJS_FIXTURE, path, None, false); // Anti-vacuity, and the more precise failure of the two: assert the // preamble still HAS the site the recogniser is written for. Without this @@ -129,7 +129,7 @@ const EXPECTED_PREAMBLE_ALLOC_STMTS: usize = 2; #[test] fn the_cjs_preamble_is_still_recognised_as_scaffolding_allocation() { let path = Path::new("/tmp/perry-canary/node_modules/dep/index.js"); - let wrapped = wrap_commonjs_for_target(CJS_FIXTURE, path, None); + let wrapped = wrap_commonjs_for_target(CJS_FIXTURE, path, None, false); // Anti-vacuity on the template, one assertion per recogniser conjunct, so // a template edit names the conjunct it broke rather than failing as an @@ -197,7 +197,7 @@ fn a_module_that_was_never_cjs_wrapped_has_no_preamble() { fn path_module_wrap_publishes_partial_then_final_exports_and_tracks_undefined() { let path = Path::new("/tmp/perry-canary/.next/server/chunks/lazy.js"); let marker = "exports.ready = true;"; - let wrapped = wrap_commonjs_for_target(marker, path, None); + let wrapped = wrap_commonjs_for_target(marker, path, None, false); let partial = wrapped .find("__perry_register_path_module_partial(") @@ -247,7 +247,7 @@ fn path_module_wrap_publishes_partial_then_final_exports_and_tracks_undefined() #[test] fn computed_relative_requires_are_joined_against_the_module_dir() { let path = Path::new("/tmp/perry-canary/.next/server/webpack-runtime.js"); - let wrapped = wrap_commonjs_for_target(CJS_FIXTURE, path, None); + let wrapped = wrap_commonjs_for_target(CJS_FIXTURE, path, None, false); // Anti-vacuity: if the wrap stops consulting the registry at all, the // assertions below would be about a branch that no longer exists. @@ -290,7 +290,7 @@ fn computed_relative_requires_are_joined_against_the_module_dir() { fn the_wrap_still_binds_the_local_the_cjs_entry_recogniser_keys_on() { let local = perry_codegen::cjs_wrap_create_require_local(); let path = Path::new("/tmp/perry-canary/node_modules/dep/index.js"); - let wrapped = wrap_commonjs_for_target(CJS_FIXTURE, path, None); + let wrapped = wrap_commonjs_for_target(CJS_FIXTURE, path, None, false); // Anti-vacuity: the template must still emit the binding at all. assert!( diff --git a/crates/perry/src/commands/compile/cjs_wrap/tests.rs b/crates/perry/src/commands/compile/cjs_wrap/tests.rs index e64cdba370..22ab9db45a 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/tests.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/tests.rs @@ -28,7 +28,7 @@ fn cjs_wrap_body_offset_maps_back_to_original_line() { // line `L - prefix_line_count`. let original = "function f() {\n return new Nope();\n}\nmodule.exports = f;\n"; let path = PathBuf::from("/tmp/x/index.js"); - let (wrapped, body_off) = wrap_commonjs_with_body_offset(original, &path, None); + let (wrapped, body_off) = wrap_commonjs_with_body_offset(original, &path, None, false); let body_off = body_off.expect("body should be locatable in wrapped output"); // Prefix line count = newlines before the body in the wrapped output. let prefix_lines = wrapped.as_bytes()[..body_off] @@ -748,6 +748,7 @@ exports.spawn = function spawn() { return terminalCtor; }; src, &PathBuf::from("/tmp/node_modules/node-pty/lib/index.js"), Some("windows"), + false, ); assert!( wrapped.contains("import _req_0 from './windowsTerminal';") @@ -783,6 +784,7 @@ exports.spawn = function spawn() { return terminalCtor; }; src, &PathBuf::from("/tmp/node_modules/node-pty/lib/index.js"), Some("linux"), + false, ); assert!( wrapped.contains("from './unixTerminal'"), diff --git a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs index 91840d4319..da26e86a93 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs @@ -100,15 +100,21 @@ fn is_global_value_builtin_name(name: &str) -> bool { /// peeking at re-export wrappers' transitive named exports. #[cfg(test)] pub(in crate::commands::compile) fn wrap_commonjs(source: &str, source_path: &Path) -> String { - wrap_commonjs_for_target(source, source_path, None) + // Not the process entry: every call site that does not know (or care) + // whether `source_path` is the compile-time entry module goes through + // here, which is correct for the overwhelming majority of CJS-wrapped + // files (dependencies). The real per-module entry status is threaded + // explicitly from `collect_modules.rs`, the only place that knows it. + wrap_commonjs_for_target(source, source_path, None, false) } pub(in crate::commands::compile) fn wrap_commonjs_for_target( source: &str, source_path: &Path, target: Option<&str>, + is_entry_module: bool, ) -> String { - wrap_commonjs_with_body_offset(source, source_path, target).0 + wrap_commonjs_with_body_offset(source, source_path, target, is_entry_module).0 } /// Like [`wrap_commonjs_for_target`], but also returns the byte offset within @@ -122,6 +128,7 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( source: &str, source_path: &Path, target: Option<&str>, + is_entry_module: bool, ) -> (String, Option) { let mut source_cow = Cow::Borrowed(source); @@ -955,6 +962,68 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( // `require(specifier)` for one of those fell through to compiled-module // resolution and raised `MODULE_NOT_FOUND` instead of routing through // `createRequire`. Each entry emits both the bare and `node:` spelling. + // #10735: `require.main` must be the process ENTRY module only — + // `module` there, unequal (or `undefined`, for an ESM entry) everywhere + // else. `codegen::entry::compile_module_entry` already published a + // placeholder object as the shared "main module" in `main()`, BEFORE any + // module ran — see `js_bootstrap_cjs_main_module_placeholder`'s doc + // comment for why that has to happen outside any module's own preamble + // (ESM eval order runs a CJS entry's own static-import dependencies + // before the entry's own top-level code, so a naive "entry publishes + // first thing in its own preamble" is too late for every hoisted + // `require('./relative')`). + // + // The entry module CLAIMS that placeholder (same object identity a + // dependency may already have captured as `require.main`) and fills in + // its real fields; every non-entry module just reads it back instead of + // building its own — the latter is what made `require.main === module` + // trivially true in every compiled CommonJS module, not just the true + // entry point (#10735). + let require_main_stmt = if is_entry_module { + "require.main = module;" + } else { + "require.main = __perry_get_cjs_main_module();" + }; + // #10735: entry-only. A non-entry module keeps the single-literal + // construction below unchanged (still recognised by + // `cjs_scaffolding.rs`'s `Ptr` folding — see the comment on that + // literal). The entry instead mutates the ALREADY-PUBLISHED placeholder + // in place, field by field, so its identity matches what a dependency + // may have captured before this preamble ran. This is entry-only (one + // object per program), so it does not reintroduce the eleven-shape- + // transition cost the folded literal below exists to avoid. + let cjs_module_init_stmt = if is_entry_module { + format!( + r#"const __cjs_module = __perry_get_cjs_main_module(); + __cjs_module.exports = {{}}; + __cjs_module.__perry_cjs_record = true; + __cjs_module.__perry_cjs_factory = {cjs_factory_value}; + __cjs_module.id = {module_filename_literal}; + __cjs_module.path = {module_dir_literal}; + __cjs_module.filename = {module_filename_literal}; + __cjs_module.loaded = false; + __cjs_module.children = []; + __cjs_module.parent = globalThis.__perry_cjs_pending_parent; + __cjs_module.paths = [{module_dir_literal} + '/node_modules']; + __cjs_module.require = undefined;"# + ) + } else { + format!( + r#"const __cjs_module = {{ + exports: {{}}, + __perry_cjs_record: true, + __perry_cjs_factory: {cjs_factory_value}, + id: {module_filename_literal}, + path: {module_dir_literal}, + filename: {module_filename_literal}, + loaded: false, + children: [], + parent: globalThis.__perry_cjs_pending_parent, + paths: [{module_dir_literal} + '/node_modules'], + require: undefined, + }};"# + ) + }; let cjs_preamble = format!( r#" // #3527: `module`/`exports` are reassignable `var`s (mirroring Node, where // they are wrapper-function parameters), so CJS bodies that do @@ -980,19 +1049,7 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( // positionally. Adding or reordering a field drops the record back to being // reported as a denied user candidate in the `Ptr` report; // `preamble_canary_tests` is what catches that. - const __cjs_module = {{ - exports: {{}}, - __perry_cjs_record: true, - __perry_cjs_factory: {cjs_factory_value}, - id: {module_filename_literal}, - path: {module_dir_literal}, - filename: {module_filename_literal}, - loaded: false, - children: [], - parent: globalThis.__perry_cjs_pending_parent, - paths: [{module_dir_literal} + '/node_modules'], - require: undefined, - }}; + {cjs_module_init_stmt} globalThis.__perry_cjs_pending_parent = undefined; // Node populates `module.parent` before the body evaluates, so link it // here rather than at the tail's registry publication. @@ -1135,7 +1192,7 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( // ~2,200 CJS modules that is pure startup garbage. require.cache = __perry_cjs_base_require.cache; require.extensions = __perry_cjs_base_require.extensions; - require.main = module;"# + {require_main_stmt}"# ); let cjs_preamble = format!( "{cjs_preamble}\n module.require = function moduleRequire(specifier) {{ return require(specifier); }};" diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index a179e14cc5..0a9474d9fd 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -408,6 +408,9 @@ fn collect_module_one( // left untouched. let was_cjs_wrapped = (is_in_compiled_pkg || !is_in_node_modules) && super::cjs_wrap::is_commonjs(&raw_source); + // #10735: this module's `require.main` (CJS preamble below) must resolve + // to the compile-time entry -- same comparison as `is_entry_module` below. + let cjs_is_entry_module = ctx.entry_canonical.as_ref() == Some(&canonical); // #5247 / #7036: when source locations are requested, capture where the // original module body lands inside the wrapped output so debug frames and // opt reports can map a wrapped-coordinate byte offset back to an @@ -415,8 +418,12 @@ fn collect_module_one( let mut cjs_wrap_body_prefix_lines: Option = None; let source = if was_cjs_wrapped { if ctx.debug_symbols { - let (wrapped, body_off) = - super::cjs_wrap::wrap_commonjs_with_body_offset(&raw_source, &canonical, target); + let (wrapped, body_off) = super::cjs_wrap::wrap_commonjs_with_body_offset( + &raw_source, + &canonical, + target, + cjs_is_entry_module, + ); // Newlines before the original body in the wrapped output = the // wrapper prefix line count. Recorded only when the body was // located; otherwise we skip the skew correction (graceful @@ -429,7 +436,12 @@ fn collect_module_one( }); wrapped } else { - super::cjs_wrap::wrap_commonjs_for_target(&raw_source, &canonical, target) + super::cjs_wrap::wrap_commonjs_for_target( + &raw_source, + &canonical, + target, + cjs_is_entry_module, + ) } } else { raw_source diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 730efc1d1b..ac1a0cd1bb 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -313,7 +313,7 @@ "file": "crates/perry-runtime/src/gc/census.rs", "name": "PASS1_MARKED", "verdict": "non_moving_snapshot", - "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete \u2192 sweep-entry window of a synchronous full \u2014 where PASS1_MARKED is populated and consumed within one `run_to_completion` \u2014 is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize \u2014 INSIDE the window \u2014 the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes \u2014 in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback. Re-audited 2026-09-18 for the #10532 follow-up argument-list rooting fix, which touched `gc/mod.rs`. The only change there is `mod collection_points;` plus a `pub(crate) use collection_points::collection_point;` re-export (and, under `#[cfg(test)]`, `arm_collection_point`). `collection_point` is an inline no-op outside `cfg(test)`; under test it only runs a copying minor when called from ordinary MUTATOR code (`proxy.rs`'s `Reflect.apply` rebind path and `registry.rs`'s rest-array bundler), never from inside `step_mark_propagation` or `step_sweep`. Neither `census_pass1_if_armed` nor `census_take_if_armed_at_full_sweep_start` is reachable from it, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-18 (same PR, round 2) for the added `arm_collection_point_after` re-export in `gc/mod.rs`: another pure re-export line, same as the `collection_point`/`arm_collection_point` one already covered above. `arm_collection_point_after` only changes test-only arming state in `collection_points.rs` (which named site fires and on which hit); it still runs no mark/sweep control flow.", + "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete \u2192 sweep-entry window of a synchronous full \u2014 where PASS1_MARKED is populated and consumed within one `run_to_completion` \u2014 is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize \u2014 INSIDE the window \u2014 the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes \u2014 in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback. Re-audited 2026-09-18 for the #10532 follow-up argument-list rooting fix, which touched `gc/mod.rs`. The only change there is `mod collection_points;` plus a `pub(crate) use collection_points::collection_point;` re-export (and, under `#[cfg(test)]`, `arm_collection_point`). `collection_point` is an inline no-op outside `cfg(test)`; under test it only runs a copying minor when called from ordinary MUTATOR code (`proxy.rs`'s `Reflect.apply` rebind path and `registry.rs`'s rest-array bundler), never from inside `step_mark_propagation` or `step_sweep`. Neither `census_pass1_if_armed` nor `census_take_if_armed_at_full_sweep_start` is reachable from it, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-18 (same PR, round 2) for the added `arm_collection_point_after` re-export in `gc/mod.rs`: another pure re-export line, same as the `collection_point`/`arm_collection_point` one already covered above. `arm_collection_point_after` only changes test-only arming state in `collection_points.rs` (which named site fires and on which hit); it still runs no mark/sweep control flow. Re-audited 2026-09-19 for #10735 (require.main threading): gc/mod.rs gains exactly one line, `reg_scanner!(crate::module_require::scan_cjs_main_module_root_mut);`, registering the new CJS_MAIN_MODULE thread-local's mutable-root scanner beside the existing `scan_module_path_roots_mut` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it runs during root scanning, before mark propagation completes, and does not execute between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -330,7 +330,7 @@ "sources": { "crates/perry-runtime/src/gc/census.rs": "5c151725460ffb92a55a6bee781123ef5159263b4ce5958d16570f78216e0d67", "crates/perry-runtime/src/gc/cycle.rs": "b035dcb44df029358cbab0afaa526e8e506765f5178034663257e18ceefaf9df", - "crates/perry-runtime/src/gc/mod.rs": "0243f1b1b1fae870983df500898abc353086473bbde3f478162e2826867762fe", + "crates/perry-runtime/src/gc/mod.rs": "59379cb96d5a3d3377fc3d34d387b509dcc1dc731679e6fd60507d844e898463", "crates/perry-runtime/src/gc/policy.rs": "895c6f4bd1a6e491adf348ecfa89985b03e354fcee7cf73826bb590f9ace9163", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } diff --git a/test-files/gap_10735_require_main_cli_guard.cjs b/test-files/gap_10735_require_main_cli_guard.cjs new file mode 100644 index 0000000000..f3a299957c --- /dev/null +++ b/test-files/gap_10735_require_main_cli_guard.cjs @@ -0,0 +1,9 @@ +// #10735 helper: the real-world shape (dotenv's bundled CLI, and countless +// other packages) — `if (require.main === module) { ...CLI... }`. Importing +// this as a dependency must NOT take the CLI branch: no "CLI" line, no +// process.exit(1). +if (require.main === module) { + console.log('CLI'); + process.exit(1); +} +exports.ok = true; diff --git a/test-files/gap_10735_require_main_deep.cjs b/test-files/gap_10735_require_main_deep.cjs new file mode 100644 index 0000000000..b8b93cf398 --- /dev/null +++ b/test-files/gap_10735_require_main_deep.cjs @@ -0,0 +1,5 @@ +// #10735 helper: required two levels deep (entry -> dep2 -> deep). Still not +// the entry, so `require.main` must still resolve to the ENTRY's module, not +// to dep2's or to this module's own. +console.log('deep (2 levels): require.main === module:', require.main === module); +exports.tag = 'deep'; diff --git a/test-files/gap_10735_require_main_dep.cjs b/test-files/gap_10735_require_main_dep.cjs new file mode 100644 index 0000000000..9a638b1f11 --- /dev/null +++ b/test-files/gap_10735_require_main_dep.cjs @@ -0,0 +1,7 @@ +// #10735 helper: a plain required dependency. `require.main` must be the +// process ENTRY module (test_gap_10735_require_main_entry.cts), never this +// module's own `module` — that was the bug (require.main === module was +// trivially true in every compiled CommonJS module). +console.log('dep: require.main === module:', require.main === module); +exports.mainRef = require.main; +exports.tag = 'dep'; diff --git a/test-files/gap_10735_require_main_dep2.cjs b/test-files/gap_10735_require_main_dep2.cjs new file mode 100644 index 0000000000..e15c68afc8 --- /dev/null +++ b/test-files/gap_10735_require_main_dep2.cjs @@ -0,0 +1,21 @@ +// #10735 helper: requires `deep.cjs` (two levels deep from the entry) and +// re-requires `dep.cjs` (already loaded by the entry directly) to exercise +// Node's module cache — the SAME dep.cjs instance and the SAME require.main +// object must come back, not a fresh copy. +// +// Requires come BEFORE any side-effecting statement (deliberately): Perry +// transpiles a static `require('./relative')` into a hoisted ESM `import`, +// which runs the imported module's top-level code before THIS module's own +// trailing statements — the reverse of Node's inline evaluation order when a +// require() is preceded by other code in the same file. That reordering is +// an existing, unrelated property of cjs_wrap's require-hoisting (not +// anything #10735 touches), so this fixture avoids it structurally, the way +// real bundled output typically does, to keep the two engines' output +// byte-for-byte comparable and isolate the require.main assertions this +// test exists to check. +const deep = require('./gap_10735_require_main_deep.cjs'); +const depAgain = require('./gap_10735_require_main_dep.cjs'); +console.log('dep2: require.main === module:', require.main === module); +exports.deepTag = deep.tag; +exports.depExportsRef = depAgain; +exports.depMainRefFromHere = depAgain.mainRef; diff --git a/test-files/gap_10735_require_main_esm_dep.cjs b/test-files/gap_10735_require_main_esm_dep.cjs new file mode 100644 index 0000000000..bb57ce923f --- /dev/null +++ b/test-files/gap_10735_require_main_esm_dep.cjs @@ -0,0 +1,7 @@ +// #10735 helper for the ESM-entry case: a CJS module reached only via +// `import` from an ESM entry has NO CommonJS "main" — Node leaves +// `require.main` as `undefined` there (verified against Node 26.5.1; this is +// not assumed). +console.log('esm-imported dep: require.main === undefined:', require.main === undefined); +console.log('esm-imported dep: typeof require.main:', typeof require.main); +module.exports = { tag: 'esm-dep' }; diff --git a/test-files/test_gap_10735_require_main_entry.cts b/test-files/test_gap_10735_require_main_entry.cts new file mode 100644 index 0000000000..dd512af43a --- /dev/null +++ b/test-files/test_gap_10735_require_main_entry.cts @@ -0,0 +1,35 @@ +// #10735: `require.main` must be the process ENTRY module only, equal to +// `module` there and unequal (or undefined, for an ESM entry — see the +// companion `test_gap_10735_require_main_esm_entry.ts`) everywhere else. +// +// Perry's CJS preamble used to emit `require.main = module;` unconditionally +// in EVERY compiled CommonJS module, so the idiom +// `if (require.main === module) { ...CLI... }` — used by countless packages +// (dotenv among them) to gate CLI behaviour — took its CLI branch whenever +// such a package was merely imported as a library. +// +// `.cts`, deliberately: this repo's package is `"type": "module"`, so a +// plain `.ts` runs as an ES module under Node; `.cts` forces CommonJS goal +// agreement between Node and Perry (see test_gap_9412's header for the full +// rationale). Keep this file free of top-level `import`/`export`. +// +// All `require()` calls come before any `console.log`, matching every +// helper file — see the ordering note in gap_10735_require_main_dep2.cjs. +const depFromEntry = require('./gap_10735_require_main_dep.cjs'); +const dep2 = require('./gap_10735_require_main_dep2.cjs'); +require('./gap_10735_require_main_cli_guard.cjs'); + +console.log('entry: require.main === module:', require.main === module); + +// Two-levels-deep dependency (entry -> dep2 -> deep) observed the same +// entry module as require.main. +console.log('deep tag:', dep2.deepTag); + +// dep.cjs was required once directly by the entry and once transitively by +// dep2 — Node's module cache means both call sites get the SAME exports +// object and the SAME require.main reference back, not a fresh reload. +console.log('dep exports identity cached across require sites:', depFromEntry === dep2.depExportsRef); +console.log('dep require.main identity stable across require sites:', depFromEntry.mainRef === dep2.depMainRefFromHere); +console.log('dep require.main === entry module:', depFromEntry.mainRef === module); + +console.log('entry done'); diff --git a/test-files/test_gap_10735_require_main_esm_entry.ts b/test-files/test_gap_10735_require_main_esm_entry.ts new file mode 100644 index 0000000000..dec4a6238e --- /dev/null +++ b/test-files/test_gap_10735_require_main_esm_entry.ts @@ -0,0 +1,6 @@ +// #10735 companion: when the process ENTRY is ESM, no CommonJS module ever +// ran as "main" — a CJS module reached only via `import` from that entry +// must see `require.main === undefined`, not merely "not itself". +import cjsDep from "./gap_10735_require_main_esm_dep.cjs"; + +console.log("esm entry imported cjs dep:", cjsDep);