From 67f25be367dbd8908e1d37bdfd9e9473e86725c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 14 Aug 2026 06:03:24 +0200 Subject: [PATCH 1/3] fix(codegen): propagate native roots to unit workers --- crates/perry-codegen/src/inprocess.rs | 13 ++- crates/perry-codegen/src/linker.rs | 45 +++++++-- .../src/linker_temp_lifecycle_tests.rs | 10 +- crates/perry-codegen/src/linker_tests.rs | 5 + crates/perry-codegen/src/native_emit.rs | 94 +++++++++++++++---- .../src/native_root_coverage/mod.rs | 1 + 6 files changed, 136 insertions(+), 32 deletions(-) diff --git a/crates/perry-codegen/src/inprocess.rs b/crates/perry-codegen/src/inprocess.rs index 0028015ec6..7d726a630a 100644 --- a/crates/perry-codegen/src/inprocess.rs +++ b/crates/perry-codegen/src/inprocess.rs @@ -181,13 +181,14 @@ pub fn compile_ll_to_object_inprocess( effective_target: &str, clang_style_args: &[String], module_name: &str, + native_roots: bool, ) -> Result> { let (opt, mcpu_native, explicit_cpu, mllvm, emit_asm) = interpret_plan_args(clang_style_args)?; // Same guard as the external `opt` path (`linker::rs4gc_funclet_refusal`): // rewrite-statepoints-for-gc crashes on WinEH funclet pads, and here the // pass runs inside THIS process — the crash would take the compiler down // with it, not just a child. - if crate::codegen::helpers::rs4gc_enabled() { + if native_roots { if let Some(refusal) = crate::linker::rs4gc_funclet_refusal(ll_text) { return Err(anyhow!(refusal)); } @@ -202,6 +203,7 @@ pub fn compile_ll_to_object_inprocess( explicit_cpu.as_deref(), &mllvm, emit_asm, + native_roots, ) } @@ -310,6 +312,7 @@ pub(crate) fn optimize_and_emit_module( module: &inkwell::module::Module<'_>, effective_target: &str, clang_style_args: &[String], + native_roots: bool, ) -> Result> { let (opt, mcpu_native, explicit_cpu, mllvm, emit_asm) = interpret_plan_args(clang_style_args)?; optimize_and_emit( @@ -320,6 +323,7 @@ pub(crate) fn optimize_and_emit_module( explicit_cpu.as_deref(), &mllvm, emit_asm, + native_roots, ) } @@ -331,6 +335,7 @@ fn optimize_and_emit( explicit_cpu: Option<&str>, mllvm: &[String], emit_asm: bool, + native_roots: bool, ) -> Result> { global_init(mllvm); announce(); @@ -398,7 +403,7 @@ fn optimize_and_emit( // backend that can root an `invoke`, and since #7302 every call inside a // `try` is one — 26% of the gap suite (128 of 479 files) contains a `try`, // which the explicit bridge refuses outright (#7327/#7330). - if crate::codegen::helpers::rs4gc_enabled() { + if native_roots { module .run_passes(STATEPOINT_REWRITE_PASSES, &tm, PassBuilderOptions::create()) .map_err(|e| { @@ -511,7 +516,7 @@ mod tests { let emit = |ir: &str, name: &str| { let context = Context::create(); let module = parse_ir_text(&context, ir, name).expect("fixture parses"); - optimize_and_emit_module(&module, &target, &["-O3".into(), "-S".into()]) + optimize_and_emit_module(&module, &target, &["-O3".into(), "-S".into()], true) .expect("fixture emits assembly") }; let text = emit(&text_ir, "constant_fold_text"); @@ -536,7 +541,7 @@ mod tests { let _shadow = crate::codegen::helpers::NativeRootsPin::shadow(); ( rewritten, - optimize_and_emit_module(&module, &target, &["-O3".into(), "-S".into()]) + optimize_and_emit_module(&module, &target, &["-O3".into(), "-S".into()], false) .expect("rewritten fixture emits assembly"), ) }; diff --git a/crates/perry-codegen/src/linker.rs b/crates/perry-codegen/src/linker.rs index e339663383..a71afae99c 100644 --- a/crates/perry-codegen/src/linker.rs +++ b/crates/perry-codegen/src/linker.rs @@ -395,6 +395,7 @@ fn build_clang_compile_plan( ll_fn_count: usize, max_fn_bytes: Option, debug_symbols: bool, + compact_gc_map: bool, ) -> ClangCompilePlan { let effective_target = target_triple .map(|s| s.to_string()) @@ -435,7 +436,6 @@ fn build_clang_compile_plan( // the statepoint backends emit a stack map, so only they pay for it, and // the cost is small: `-S` takes the same time as `-c` (codegen is the // cost, printing text is free) and assembling is ~0.02s per module. - let compact_gc_map = crate::codegen::helpers::native_stack_roots_enabled(); let asm_path = compact_gc_map.then(|| PathBuf::from(format!("{}.s", obj_path.display()))); let mut clang_args = vec![ @@ -542,8 +542,8 @@ pub(crate) fn rs4gc_funclet_refusal(ll_text: &str) -> Option { }) } -fn maybe_rs4gc_preprocess(ll_text: &str) -> Result> { - if !crate::codegen::helpers::rs4gc_enabled() { +fn maybe_rs4gc_preprocess(ll_text: &str, native_roots: bool) -> Result> { + if !native_roots { return Ok(None); } if let Some(refusal) = rs4gc_funclet_refusal(ll_text) { @@ -629,13 +629,23 @@ fn which_in_path(name: &str) -> Option { } pub fn compile_ll_to_object(ll_text: &str, target_triple: Option<&str>) -> Result> { - let rs4gc_ll = maybe_rs4gc_preprocess(ll_text)?; + let native_roots = crate::codegen::helpers::native_stack_roots_enabled(); + compile_ll_to_object_with_native_roots(ll_text, target_triple, native_roots) +} + +fn compile_ll_to_object_with_native_roots( + ll_text: &str, + target_triple: Option<&str>, + native_roots: bool, +) -> Result> { + let rs4gc_ll = maybe_rs4gc_preprocess(ll_text, native_roots)?; let ll_text: &str = rs4gc_ll.as_deref().unwrap_or(ll_text); compile_ll_to_object_in( &env::temp_dir(), ll_text, target_triple, TempFilePolicy::from_env(), + native_roots, ) } @@ -671,6 +681,7 @@ pub(crate) fn native_plan_args( est_ll_bytes: usize, ll_fn_count: usize, max_fn_bytes: usize, + native_roots: bool, ) -> (String, Vec) { let plan = build_clang_compile_plan( PathBuf::from("(in-process)"), @@ -681,6 +692,7 @@ pub(crate) fn native_plan_args( ll_fn_count, Some(max_fn_bytes), env::var_os("PERRY_DEBUG_SYMBOLS").is_some(), + native_roots, ); (plan.effective_target, plan.clang_args) } @@ -784,6 +796,7 @@ fn compile_ll_inprocess_in( ll_text: &str, target_triple: Option<&str>, policy: TempFilePolicy, + native_roots: bool, ) -> Result> { let (paths, _pid, _nonce) = llvm_temp_paths(tmp_dir, ll_text); // Same decision inputs as the clang path — opt level (#4880 fallback @@ -798,6 +811,7 @@ fn compile_ll_inprocess_in( count_ll_functions(ll_text), None, policy.debug_symbols, + native_roots, ); // #7131 parity: the module identifier is the content-addressed basename, // the only name that can reach the object bytes. @@ -824,6 +838,7 @@ fn compile_ll_inprocess_in( &plan.effective_target, &plan.clang_args, &module_name, + native_roots, ) { // The statepoint backends ask for `-S`, because #7314's compact-map // rewriter rewrites `.llvm_stackmaps` at ASSEMBLY time — that is where @@ -910,6 +925,7 @@ fn compile_ll_inprocess_in( _ll_text: &str, _target_triple: Option<&str>, _policy: TempFilePolicy, + _native_roots: bool, ) -> Result> { // Fail loudly rather than silently falling back: an A/B arm that asked // for the in-process backend must never be served the text path. @@ -927,9 +943,10 @@ fn compile_ll_to_object_in( ll_text: &str, target_triple: Option<&str>, policy: TempFilePolicy, + native_roots: bool, ) -> Result> { if inprocess_requested() { - return compile_ll_inprocess_in(tmp_dir, ll_text, target_triple, policy); + return compile_ll_inprocess_in(tmp_dir, ll_text, target_triple, policy, native_roots); } // Validate the toolchain before creating the potentially large `.ll` // scratch file. Unsupported clang releases should fail without leaving @@ -979,6 +996,7 @@ fn compile_ll_to_object_in( count_ll_functions(ll_text), None, policy.debug_symbols, + native_roots, ); // Pre-flight probe: capture clang's default Target: line once per process, @@ -1133,10 +1151,19 @@ pub fn compile_units_to_object(units: &[String], target_triple: Option<&str>) -> }) .min(units.len()); + // The root backend is a per-module decision stored on the producer thread. + // Unit workers must receive it explicitly: fresh threads start with the + // thread-local target/override cells unset and would otherwise silently + // compile precise-root IR without RS4GC or a compact map (#8070). + let native_roots = crate::codegen::helpers::native_stack_roots_enabled(); let mut compiled: Vec>>> = (0..units.len()).map(|_| None).collect(); if jobs <= 1 { for (i, unit) in units.iter().enumerate() { - compiled[i] = Some(compile_ll_to_object(unit, target_triple)); + compiled[i] = Some(compile_ll_to_object_with_native_roots( + unit, + target_triple, + native_roots, + )); } } else { let slots: Vec>>>> = (0..units.len()) @@ -1150,7 +1177,11 @@ pub fn compile_units_to_object(units: &[String], target_triple: Option<&str>) -> if i >= units.len() { break; } - let out = compile_ll_to_object(&units[i], target_triple); + let out = compile_ll_to_object_with_native_roots( + &units[i], + target_triple, + native_roots, + ); *slots[i].lock().expect("codegen-unit slot poisoned") = Some(out); }); } diff --git a/crates/perry-codegen/src/linker_temp_lifecycle_tests.rs b/crates/perry-codegen/src/linker_temp_lifecycle_tests.rs index 4b372241b8..67e8039813 100644 --- a/crates/perry-codegen/src/linker_temp_lifecycle_tests.rs +++ b/crates/perry-codegen/src/linker_temp_lifecycle_tests.rs @@ -121,7 +121,7 @@ fn successful_compile_leaves_nothing_behind() { }; for nth in 0..3 { - let bytes = compile_ll_to_object_in(&root, &test_ir(nth), None, CLEAN) + let bytes = compile_ll_to_object_in(&root, &test_ir(nth), None, CLEAN, false) .unwrap_or_else(|e| panic!("compile {nth} failed: {e:#}")); assert!(!bytes.is_empty(), "compile {nth} produced no object bytes"); assert_eq!( @@ -162,7 +162,7 @@ fn concurrent_compiles_of_identical_ir_both_succeed_and_leave_nothing() { .map(|_| { let root = root.clone(); let ir = ir.clone(); - s.spawn(move || compile_ll_to_object_in(&root, &ir, None, CLEAN)) + s.spawn(move || compile_ll_to_object_in(&root, &ir, None, CLEAN, false)) }) .collect(); handles.into_iter().map(|h| h.join().unwrap()).collect() @@ -196,7 +196,7 @@ fn failed_compile_keeps_the_ll_for_diagnosis() { return; }; - let err = compile_ll_to_object_in(&root, "this is not LLVM IR\n", None, CLEAN) + let err = compile_ll_to_object_in(&root, "this is not LLVM IR\n", None, CLEAN, false) .expect_err("clang must reject non-IR input"); let message = format!("{err:#}"); assert!( @@ -251,7 +251,7 @@ fn keep_ir_retains_the_whole_scratch_dir() { keep: true, debug_symbols: false, }; - compile_ll_to_object_in(&root, &test_ir(7), None, policy).expect("compile failed"); + compile_ll_to_object_in(&root, &test_ir(7), None, policy, false).expect("compile failed"); let left = entries(&root); assert_eq!(left.len(), 1, "expected one kept scratch dir: {left:?}"); @@ -283,7 +283,7 @@ fn debug_symbols_do_not_change_the_temp_file_lifetime() { debug_symbols: true, }; for nth in 0..2 { - compile_ll_to_object_in(&root, &test_ir(9 + nth), None, policy) + compile_ll_to_object_in(&root, &test_ir(9 + nth), None, policy, false) .unwrap_or_else(|e| panic!("-g compile {nth} failed: {e:#}")); assert_eq!( entries(&root), diff --git a/crates/perry-codegen/src/linker_tests.rs b/crates/perry-codegen/src/linker_tests.rs index 3b1dd6f84d..0207ffcc5b 100644 --- a/crates/perry-codegen/src/linker_tests.rs +++ b/crates/perry-codegen/src/linker_tests.rs @@ -167,6 +167,7 @@ fn compile_plan_records_effective_target_and_native_tuning() { 0, None, false, + false, ); assert!(plan.clang_args.contains(&"-fno-math-errno".to_string())); // Small module → optimized at -O3 (#4880). @@ -202,6 +203,7 @@ fn compile_plan_size_optimizes_oversized_many_function_module() { many_funcs, None, false, + false, ); assert!(plan.clang_args.contains(&"-Os".to_string())); assert!(!plan.clang_args.contains(&"-O3".to_string())); @@ -223,6 +225,7 @@ fn compile_plan_keeps_o0_for_oversized_giant_function_monolith() { 2, // ~3 MB/fn — far above the density cap None, false, + false, ); assert!(plan.clang_args.contains(&"-O0".to_string())); assert!(!plan.clang_args.contains(&"-O3".to_string())); @@ -240,6 +243,7 @@ fn compile_plan_skips_native_tuning_for_explicit_target() { 0, None, false, + false, ); assert_eq!(plan.effective_target, "x86_64-unknown-linux-gnu"); assert_eq!(plan.native_tuning_arg, None); @@ -334,6 +338,7 @@ fn compile_plan_metadata_json_contains_object_source() { 0, None, false, + false, ); write_compile_plan_metadata(&plan, &temp).unwrap(); let text = fs::read_to_string(&temp).unwrap(); diff --git a/crates/perry-codegen/src/native_emit.rs b/crates/perry-codegen/src/native_emit.rs index a36ba653ef..3d84cc067f 100644 --- a/crates/perry-codegen/src/native_emit.rs +++ b/crates/perry-codegen/src/native_emit.rs @@ -305,6 +305,10 @@ pub fn compile_module_units_native( Ok("1" | "all") ) || std::env::var("PERRY_CODEGEN_UNIT_TIMINGS").is_ok(); let unit_total = parts.len(); + // Root lowering was selected while the module was produced. Preserve that + // exact backend choice across the worker boundary instead of re-reading + // fresh thread-local defaults in each LLVM thread (#8070). + let native_roots = crate::codegen::helpers::native_stack_roots_enabled(); if show_progress { eprintln!( "[perry] codegen: {module_prefix}: freezing {unit_total} codegen units for worker threads" @@ -331,10 +335,15 @@ pub fn compile_module_units_native( unit.estimated_bytes, unit.function_count, unit.max_function_bytes, + native_roots, ); - let unit_bytes = - crate::inprocess::optimize_and_emit_module(&module, &effective_target, &args) - .map_err(|e| anyhow!("unit {i}: {e:#}"))?; + let unit_bytes = crate::inprocess::optimize_and_emit_module( + &module, + &effective_target, + &args, + native_roots, + ) + .map_err(|e| anyhow!("unit {i}: {e:#}"))?; let obj = crate::linker::finish_native_emission(unit_bytes, &effective_target, &args) .map_err(|e| anyhow!("unit {i}: {e:#}"))?; log::debug!( @@ -508,7 +517,7 @@ pub fn compile_module_units_diff( /// The plan argv for a natively-built module. Same decision code as the text /// path (`build_clang_compile_plan`), with the byte-size input taken from the /// render-free size estimate the codegen-unit balancer already uses. -fn plan_for(llmod: &LlModule, target: Option<&str>) -> (String, Vec) { +fn plan_for(llmod: &LlModule, target: Option<&str>, native_roots: bool) -> (String, Vec) { let funcs = llmod.deduped_function_refs(); let est_bytes: usize = funcs.iter().map(|f| f.estimated_ir_bytes()).sum(); let max_fn_bytes = funcs @@ -516,7 +525,7 @@ fn plan_for(llmod: &LlModule, target: Option<&str>) -> (String, Vec) { .map(|f| f.estimated_ir_bytes()) .max() .unwrap_or(0); - crate::linker::native_plan_args(target, est_bytes, funcs.len(), max_fn_bytes) + crate::linker::native_plan_args(target, est_bytes, funcs.len(), max_fn_bytes, native_roots) } pub fn compile_module_native( @@ -527,13 +536,19 @@ pub fn compile_module_native( let context = Context::create(); let module = build_native_module(&context, llmod)?; debug_dump(&module, module_prefix); - let (effective_target, args) = plan_for(llmod, target); + let native_roots = crate::codegen::helpers::native_stack_roots_enabled(); + let (effective_target, args) = plan_for(llmod, target, native_roots); // #7982: under the statepoint backends the plan asks for `-S`, so this // returns assembler TEXT. It must go through the compact-map rewrite and // the assembler before it can be called an object — the textual path has // always done this, the native path silently did not, and the link died // with `ld: unknown file type`. - let bytes = crate::inprocess::optimize_and_emit_module(&module, &effective_target, &args)?; + let bytes = crate::inprocess::optimize_and_emit_module( + &module, + &effective_target, + &args, + native_roots, + )?; crate::linker::finish_native_emission(bytes, &effective_target, &args) } @@ -662,6 +677,26 @@ mod tests { } } + fn assert_compact_gc_map(object: &[u8], label: &str) { + let section_name: &[u8] = if cfg!(target_os = "macos") { + b"__perry_gcmap" + } else if cfg!(target_os = "windows") { + b".pgcmap" + } else { + b".perry_gcmap" + }; + assert!( + object + .windows(section_name.len()) + .any(|window| window == section_name), + "{label} object has no compact GC-map section" + ); + assert!( + object.windows(4).any(|window| window == b"PGCM"), + "{label} compact GC-map section has no map payload" + ); + } + #[test] fn native_construction_lowers_precise_roots_before_rs4gc() { let _native = crate::codegen::helpers::NativeRootsPin::native(); @@ -696,8 +731,21 @@ mod tests { assert_dynamic_root_survives_rs4gc(&text_module, "split"); let units = text_module.render_codegen_units(2); assert_eq!(units.len(), 2, "fixture must exercise two real units"); - let text = crate::linker::compile_units_to_object(&units, None) - .expect("trusted text units emit and partial-link"); + // Compile the trusted units sequentially on this pinned producer + // thread. Going through compile_units_to_object here made the control + // machine-dependent: on a high-core host it spawned workers too, both + // arms lost the same thread-local decision, and byte equality passed + // while BOTH objects omitted the map (#8070). + let text_objects = units + .iter() + .map(|unit| { + crate::linker::compile_ll_to_object(unit, None) + .expect("trusted text unit emits an object") + }) + .collect::>(); + let text = crate::linker::merge_unit_objects(&text_objects) + .expect("trusted text units partial-link"); + assert_compact_gc_map(&text, "trusted text"); let mut native_module = precise_root_fixture(true); let native = compile_module_units_native( @@ -707,6 +755,7 @@ mod tests { "split_native_root_diff_fixture", ) .expect("direct native units emit and partial-link"); + assert_compact_gc_map(&native, "split native"); assert_eq!( native, text, "split native units must freeze finalized precise-root IR, not \ @@ -763,15 +812,20 @@ pub fn compile_module_diff( let text = llmod.to_ir(); let ctx_text = Context::create(); let m_text = crate::inprocess::parse_ir_text(&ctx_text, &text, "perry_native_module")?; - let (effective_target, args) = plan_for(llmod, target); + let native_roots = crate::codegen::helpers::native_stack_roots_enabled(); + let (effective_target, args) = plan_for(llmod, target, native_roots); let ctx_native = Context::create(); let native = build_native_module(&ctx_native, llmod); match native { Err(e) => { eprintln!("perry: [ir-diff] native construction FAILED (text arm still used): {e:#}"); - let bytes = - crate::inprocess::optimize_and_emit_module(&m_text, &effective_target, &args)?; + let bytes = crate::inprocess::optimize_and_emit_module( + &m_text, + &effective_target, + &args, + native_roots, + )?; crate::linker::finish_native_emission(bytes, &effective_target, &args) } Ok(m_native) => { @@ -787,10 +841,18 @@ pub fn compile_module_diff( } else { (String::new(), String::new()) }; - let bytes_native = - crate::inprocess::optimize_and_emit_module(&m_native, &effective_target, &args)?; - let bytes_text = - crate::inprocess::optimize_and_emit_module(&m_text, &effective_target, &args)?; + let bytes_native = crate::inprocess::optimize_and_emit_module( + &m_native, + &effective_target, + &args, + native_roots, + )?; + let bytes_text = crate::inprocess::optimize_and_emit_module( + &m_text, + &effective_target, + &args, + native_roots, + )?; if bytes_text == bytes_native { eprintln!( "perry: [ir-diff] OK — native and text arms emit byte-identical objects \ diff --git a/crates/perry-codegen/src/native_root_coverage/mod.rs b/crates/perry-codegen/src/native_root_coverage/mod.rs index c960c46e23..8785e8bec6 100644 --- a/crates/perry-codegen/src/native_root_coverage/mod.rs +++ b/crates/perry-codegen/src/native_root_coverage/mod.rs @@ -497,6 +497,7 @@ pub(crate) fn assembly_for(ir: &str, target: &str) -> String { &module, target, &["-O0".to_string(), "-S".to_string()], + true, ) .unwrap_or_else(|e| panic!("assembly emission failed for {target}: {e:#}")); String::from_utf8(bytes).expect("assembler text should be UTF-8") From 49b4c037c85908d8fa41f35e8b5e1057aac73f00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 14 Aug 2026 06:12:47 +0200 Subject: [PATCH 2/3] test(codegen): cover split shadow worker decision --- crates/perry-codegen/src/native_emit.rs | 87 ++++++++++++++++++++----- 1 file changed, 71 insertions(+), 16 deletions(-) diff --git a/crates/perry-codegen/src/native_emit.rs b/crates/perry-codegen/src/native_emit.rs index 3d84cc067f..fe41b97e27 100644 --- a/crates/perry-codegen/src/native_emit.rs +++ b/crates/perry-codegen/src/native_emit.rs @@ -595,6 +595,8 @@ mod tests { fn precise_root_fixture(extra_plain_function: bool) -> LlModule { let mut module = LlModule::new(crate::codegen::default_target_triple()); + module.declare_function_with_ret_attrs("js_shadow_frame_enter", PTR, &[I32], "nonnull"); + module.declare_function("js_shadow_frame_pop", VOID, &[I64]); module.declare_function("js_shadow_slot_bind", VOID, &[I32, PTR]); module.declare_function("js_map_alloc", I64, &[I32]); module.declare_function("may_collect", I64, &[]); @@ -677,26 +679,54 @@ mod tests { } } - fn assert_compact_gc_map(object: &[u8], label: &str) { - let section_name: &[u8] = if cfg!(target_os = "macos") { + fn compact_gc_map_section_name() -> &'static [u8] { + if cfg!(target_os = "macos") { b"__perry_gcmap" } else if cfg!(target_os = "windows") { b".pgcmap" } else { b".perry_gcmap" - }; + } + } + + fn object_contains(object: &[u8], needle: &[u8]) -> bool { + object.windows(needle.len()).any(|window| window == needle) + } + + fn assert_compact_gc_map(object: &[u8], label: &str) { + let section_name = compact_gc_map_section_name(); assert!( - object - .windows(section_name.len()) - .any(|window| window == section_name), + object_contains(object, section_name), "{label} object has no compact GC-map section" ); assert!( - object.windows(4).any(|window| window == b"PGCM"), + object_contains(object, b"PGCM"), "{label} compact GC-map section has no map payload" ); } + fn assert_no_compact_gc_map(object: &[u8], label: &str) { + assert!( + !object_contains(object, compact_gc_map_section_name()), + "{label} shadow-stack object unexpectedly has a compact GC-map section" + ); + assert!( + !object_contains(object, b"PGCM"), + "{label} shadow-stack object unexpectedly has a compact GC-map payload" + ); + } + + fn compile_text_units_on_producer(units: &[String]) -> Vec { + let objects = units + .iter() + .map(|unit| { + crate::linker::compile_ll_to_object(unit, None) + .expect("trusted text unit emits an object") + }) + .collect::>(); + crate::linker::merge_unit_objects(&objects).expect("trusted text units partial-link") + } + #[test] fn native_construction_lowers_precise_roots_before_rs4gc() { let _native = crate::codegen::helpers::NativeRootsPin::native(); @@ -736,15 +766,7 @@ mod tests { // machine-dependent: on a high-core host it spawned workers too, both // arms lost the same thread-local decision, and byte equality passed // while BOTH objects omitted the map (#8070). - let text_objects = units - .iter() - .map(|unit| { - crate::linker::compile_ll_to_object(unit, None) - .expect("trusted text unit emits an object") - }) - .collect::>(); - let text = crate::linker::merge_unit_objects(&text_objects) - .expect("trusted text units partial-link"); + let text = compile_text_units_on_producer(&units); assert_compact_gc_map(&text, "trusted text"); let mut native_module = precise_root_fixture(true); @@ -763,6 +785,39 @@ mod tests { ); } + #[test] + fn split_native_construction_propagates_shadow_backend_to_workers() { + let _shadow = crate::codegen::helpers::NativeRootsPin::shadow(); + let text_module = precise_root_fixture(true); + let text_ir = text_module.to_ir(); + assert!( + text_ir.contains("call void @js_shadow_slot_bind"), + "negative control must demonstrably use the shadow-stack lowering:\n{text_ir}" + ); + assert!( + !text_ir.contains("alloca ptr addrspace(1)"), + "negative control must not contain native-stack root allocas:\n{text_ir}" + ); + let units = text_module.render_codegen_units(2); + assert_eq!(units.len(), 2, "fixture must exercise two real units"); + let text = compile_text_units_on_producer(&units); + assert_no_compact_gc_map(&text, "trusted text"); + + let mut native_module = precise_root_fixture(true); + let native = compile_module_units_native( + &mut native_module, + 2, + None, + "split_shadow_root_diff_fixture", + ) + .expect("direct shadow-stack native units emit and partial-link"); + assert_no_compact_gc_map(&native, "split native"); + assert_eq!( + native, text, + "split native workers must preserve the producer's shadow-stack backend decision" + ); + } + #[test] fn split_units_emit_and_merge_init_body_pointer_constant() { // Production webpack modules are large enough to use split native From 11b61e5d0aeaa82bd2affb55d0b65d5836e01140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 14 Aug 2026 06:13:54 +0200 Subject: [PATCH 3/3] docs: add split native roots changelog --- changelog.d/8071-split-native-root-workers.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/8071-split-native-root-workers.md diff --git a/changelog.d/8071-split-native-root-workers.md b/changelog.d/8071-split-native-root-workers.md new file mode 100644 index 0000000000..b15a590155 --- /dev/null +++ b/changelog.d/8071-split-native-root-workers.md @@ -0,0 +1,3 @@ +### Fixed + +- Propagate the producer-selected root backend into split LLVM codegen workers so native-root units cannot skip statepoint rewriting and compact GC-map emission (#8070).