From bd69dfad16eb1fdf97882f73def054415aadf7fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 12:25:25 +0200 Subject: [PATCH 1/2] Share exact retained source ranges without changing reflection ownership --- Cargo.lock | 1 + Cargo.toml | 1 + .../pending-retained-source-range-pool.md | 2 + crates/perry-codegen/Cargo.toml | 1 + .../src/codegen/emission_order_tests.rs | 118 +++++++++ crates/perry-codegen/src/codegen/mod.rs | 1 + .../src/codegen/retained_source_pool.rs | 240 ++++++++++++++++++ .../perry-codegen/src/codegen/string_pool.rs | 79 +++--- scripts/test-retained-source-pool.mjs | 84 ++++++ test-files/test_gap_retained_source_pool.ts | 28 ++ 10 files changed, 519 insertions(+), 36 deletions(-) create mode 100644 changelog.d/pending-retained-source-range-pool.md create mode 100644 crates/perry-codegen/src/codegen/retained_source_pool.rs create mode 100644 scripts/test-retained-source-pool.mjs create mode 100644 test-files/test_gap_retained_source_pool.ts diff --git a/Cargo.lock b/Cargo.lock index 8e63c9136d..59000ea35c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5778,6 +5778,7 @@ dependencies = [ name = "perry-codegen" version = "0.5.1532" dependencies = [ + "aho-corasick", "anyhow", "inkwell", "llvm-sys", diff --git a/Cargo.toml b/Cargo.toml index 80ff5c7028..c63f0eede4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -394,6 +394,7 @@ libc = "0.2" lazy_static = "1.5" chrono = "0.4" regex = "1.12" +aho-corasick = "1.1" regress = "0.11.1" hex = "0.4" tempfile = "3" diff --git a/changelog.d/pending-retained-source-range-pool.md b/changelog.d/pending-retained-source-range-pool.md new file mode 100644 index 0000000000..9a29a3264c --- /dev/null +++ b/changelog.d/pending-retained-source-range-pool.md @@ -0,0 +1,2 @@ +- Share exact nested function, closure, class and method source bytes within each compiled module. Reflection retains the original byte lengths, strictness metadata, registration order and copying/static ownership APIs; bounded matching falls back to independent constants. +- Add deterministic range/budget tests, emitted-IR ownership/registration coverage and an application-independent reflection/GC gap fixture with a bounded default/compact native driver. Uses the already-locked aho-corasick dependency; no runtime ABI change or application-specific source rewriting. diff --git a/crates/perry-codegen/Cargo.toml b/crates/perry-codegen/Cargo.toml index 05faf97664..a2f64de4fd 100644 --- a/crates/perry-codegen/Cargo.toml +++ b/crates/perry-codegen/Cargo.toml @@ -46,6 +46,7 @@ perry-dispatch.workspace = true perry-api-manifest.workspace = true anyhow.workspace = true +aho-corasick.workspace = true thiserror.workspace = true log.workspace = true serde.workspace = true diff --git a/crates/perry-codegen/src/codegen/emission_order_tests.rs b/crates/perry-codegen/src/codegen/emission_order_tests.rs index d19e2e1576..771b31ac4c 100644 --- a/crates/perry-codegen/src/codegen/emission_order_tests.rs +++ b/crates/perry-codegen/src/codegen/emission_order_tests.rs @@ -492,3 +492,121 @@ fn registration_spelling_follows_output_kind() { ); } } + +/// Exercise the real string-pool emitter, including class/method source, +/// nonzero UTF-8 byte offsets, explicit lengths, registration order and both +/// lifetime contracts. The padding crosses the production matcher's threshold. +#[test] +fn retained_source_ranges_preserve_registrations_and_ownership() { + let inner = "function inner() { return 1; }"; + let method = "m() { return 1; }"; + let class = format!("class C {{ {method} }}"); + let outer = format!( + "function outer() {{ /*__retained_source_parent__{}\0*/ {inner} {class} }}", + "世界".repeat(700) + ); + let make_module = || { + let mut module = empty_module("retained_source_ranges.ts"); + module.functions.push(method_fn(100, "outer")); + module.functions.push(method_fn(101, "inner")); + module + .classes + .push(plain_class(3, "C", method_fn(200, "m"))); + for (id, source, flag) in [ + (100, outer.as_str(), true), + (101, inner, false), + (200, method, false), + ] { + module.closure_source_text.insert( + id, + perry_hir::FunctionSourceMetadata { + text: source.to_owned(), + is_non_strict_ordinary: flag, + }, + ); + } + module.class_source_text.insert(3, class.clone()); + module + }; + for output_type in ["executable", "dylib", "staticlib"] { + let emitted = ir_for_output_type(&make_module(), output_type); + assert_eq!( + emitted, + ir_for_output_type(&make_module(), output_type), + "deterministic source globals and registrations" + ); + let source_globals: Vec<_> = emitted + .lines() + .filter(|line| { + line.starts_with('@') && line.contains("constant") && line.contains(inner) + }) + .collect(); + assert_eq!( + source_globals.len(), + 1, + "nested sources must share one physical parent constant" + ); + let base = source_globals[0].split_whitespace().next().unwrap(); + let pointer = |source: &str| { + let offset = outer.find(source).unwrap(); + if offset == 0 { + return base.to_owned(); + } + let gep = emitted + .lines() + .find(|line| { + line.contains("getelementptr") + && line.contains(&format!("ptr {base}, i64 {offset}")) + }) + .expect("nonzero source offset must actually be emitted"); + gep.trim().split(" = ").next().unwrap().to_owned() + }; + let register = if output_type == "executable" { + "js_register_function_source_static" + } else { + "js_register_function_source" + }; + let calls: Vec<_> = emitted + .lines() + .filter(|line| line.contains("call void @js_register_function_source")) + .collect(); + assert_eq!( + calls.len(), + 3, + "two functions and one materialized method must be registered" + ); + for (call, (symbol, source, flag)) in calls.iter().zip([ + ("__outer", outer.as_str(), 1), + ("__inner", inner, 0), + ("__C__m", method, 0), + ]) { + assert!(call.contains(&format!("@{register}(")), "{call}"); + assert!( + call.contains(&format!("{symbol},")), + "registration order/symbol changed: {call}" + ); + assert!( + call.contains(&format!( + "ptr {}, i32 {}, i32 {flag})", + pointer(source), + source.len() + )), + "source range/length/strictness changed: {call}" + ); + } + let class_calls: Vec<_> = emitted + .lines() + .filter(|line| line.contains("call void @js_register_class_source(")) + .collect(); + assert_eq!(class_calls.len(), 1); + assert!( + class_calls[0].contains(&format!( + "(i32 3, ptr {}, i32 {})", + pointer(&class), + class.len() + )), + "class source must retain its copying API: {}", + class_calls[0] + ); + } +} diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index d3d84cb5a1..2c93833b55 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -220,6 +220,7 @@ mod opts; #[cfg(test)] mod ordinary_param_guard_tests; mod param_guard; +mod retained_source_pool; mod spec_abi; #[cfg(test)] mod spec_preserve_none_tests; diff --git a/crates/perry-codegen/src/codegen/retained_source_pool.rs b/crates/perry-codegen/src/codegen/retained_source_pool.rs new file mode 100644 index 0000000000..dad12102c9 --- /dev/null +++ b/crates/perry-codegen/src/codegen/retained_source_pool.rs @@ -0,0 +1,240 @@ +//! Share exact nested function/class source bytes, without changing runtime +//! registration, strictness metadata or copying vs process-lifetime ownership. +use std::collections::HashMap; + +use aho_corasick::{AhoCorasickBuilder, AhoCorasickKind}; + +use crate::{ + block::LlBlock, + module::LlModule, + types::{I64, I8}, +}; + +const MIN_PATTERN_BYTES: usize = 4096; +const MAX_PATTERN_BYTES: usize = 8 * 1024 * 1024; +const MAX_MATCHES: usize = 1_000_000; + +#[derive(Clone)] +pub(super) struct SourceRange { + global: String, + offset: usize, + pub(super) byte_len: usize, +} + +impl SourceRange { + pub(super) fn pointer(&self, block: &mut LlBlock) -> String { + let base = format!("@{}", self.global); + if self.offset == 0 { + base + } else { + block.gep(I8, &base, &[(I64, &self.offset.to_string())]) + } + } +} + +pub(super) struct SourcePool<'a>(HashMap<&'a str, SourceRange>); + +impl<'a> SourcePool<'a> { + pub(super) fn emit(llmod: &mut LlModule, sources: impl Iterator) -> Self { + // Intern before building the matcher. Hash iteration never determines + // emission order: first occurrence supplies every stable index. + let mut unique = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for source in sources { + if seen.insert(source) { + unique.push(source); + } + } + let bytes: Vec<&[u8]> = unique.iter().map(|source| source.as_bytes()).collect(); + let plan = plan(&bytes, MIN_PATTERN_BYTES, MAX_PATTERN_BYTES, MAX_MATCHES); + let mut globals = HashMap::new(); + // Keep physical constants in original first-use order as well. + for (idx, &(parent, _)) in plan.iter().enumerate() { + if idx == parent { + globals.insert(idx, llmod.add_string_constant(unique[idx]).0); + } + } + Self( + unique + .into_iter() + .enumerate() + .map(|(idx, source)| { + let (parent, offset) = plan[idx]; + ( + source, + SourceRange { + global: globals[&parent].clone(), + offset, + byte_len: source.len(), + }, + ) + }) + .collect(), + ) + } + + pub(super) fn get(&self, source: &str) -> SourceRange { + self.0 + .get(source) + .expect("retained source was prepared") + .clone() + } +} + +/// Each result names an input parent and an exact byte offset. Budget/build +/// failure keeps independent byte ranges, never a guessed match or missing text. +fn plan( + input: &[&[u8]], + minimum_bytes: usize, + maximum_bytes: usize, + maximum_matches: usize, +) -> Vec<(usize, usize)> { + let raw = || (0..input.len()).map(|idx| (idx, 0)).collect(); + let total = input + .iter() + .fold(0usize, |sum, bytes| sum.saturating_add(bytes.len())); + if input.len() < 2 + || total < minimum_bytes + || total > maximum_bytes + || input.iter().any(|bytes| bytes.is_empty()) + || maximum_matches == 0 + { + return raw(); + } + // A contiguous NFA avoids the potentially much larger dense DFA. Pattern + // bytes and reported matches are bounded independently of source syntax. + let Ok(automaton) = AhoCorasickBuilder::new() + .kind(Some(AhoCorasickKind::ContiguousNFA)) + .build(input) + else { + return raw(); + }; + let mut order: Vec = (0..input.len()).collect(); + order.sort_by_key(|&idx| (std::cmp::Reverse(input[idx].len()), idx)); + let mut locations = vec![None; input.len()]; + let mut matches = 0; + for parent in order { + if locations[parent].is_some() { + continue; + } + locations[parent] = Some((parent, 0)); + if matches == maximum_matches { + continue; + } + for found in automaton.find_overlapping_iter(input[parent]) { + let child = found.pattern().as_usize(); + // The matcher reports byte offsets. No UTF-8 normalization, + // NUL termination or original-module coordinate inference occurs. + locations[child].get_or_insert((parent, found.start())); + matches += 1; + if matches == maximum_matches { + break; + } + } + } + locations + .into_iter() + .map(|location| location.expect("every source has a parent")) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn verify( + input: &[&[u8]], + minimum: usize, + maximum: usize, + matches: usize, + ) -> Vec<(usize, usize)> { + let result = plan(input, minimum, maximum, matches); + for (&expected, &(parent, offset)) in input.iter().zip(&result) { + assert_eq!(expected, &input[parent][offset..offset + expected.len()]); + assert_eq!( + result[parent], + (parent, 0), + "parents must not form alias chains" + ); + } + result + } + + #[test] + fn nested_unicode_nul_binary_and_deterministic_ties() { + let input: &[&[u8]] = &[ + b"inner", + b"outer(inner)\0\xff", + b"(inner)", + "世界".as_bytes(), + "function 世界() { return '世界'; }".as_bytes(), + b"[inner]", + ]; + let first = verify(input, 0, 1024, 100); + assert_eq!(first[0], (1, 6)); + assert_eq!(first[2], (1, 5)); + assert_eq!(first[3], (4, 9)); + assert_eq!(first[5], (5, 0)); + for _ in 0..16 { + assert_eq!(verify(input, 0, 1024, 100), first); + } + } + + #[test] + fn budgets_and_empty_sources_keep_exact_fallbacks() { + let input: &[&[u8]] = &[b"a", b"aa", b"aaa", b"aaaa", b"b"]; + for bound in [0, 1, 2, 3, 8, 100] { + verify(input, 0, 1024, bound); + } + let raw: Vec<_> = (0..input.len()).map(|idx| (idx, 0)).collect(); + assert_eq!(verify(input, 1024, 2048, 100), raw); + assert_eq!(verify(input, 0, 1, 100), raw); + verify(&[b"", b"hello"], 0, 1024, 100); + verify(&[], 0, 1024, 100); + } + + #[test] + fn emitted_pool_deduplicates_small_sources_without_a_matcher() { + let mut module = LlModule::new("aarch64-apple-darwin"); + let pool = SourcePool::emit(&mut module, ["small", "other", "small", ""].into_iter()); + assert_eq!(pool.get("small").global, ".str.0"); + assert_eq!(pool.get("other").global, ".str.1"); + assert_eq!(pool.get("").global, ".str.2"); + assert_eq!(pool.0.len(), 3); + assert_eq!(pool.get("").byte_len, 0); + assert_eq!( + module + .to_ir() + .lines() + .filter(|line| line.contains("private unnamed_addr constant")) + .count(), + 3 + ); + } + + #[test] + fn emitted_pool_shares_nonzero_utf8_ranges_and_only_emits_parents() { + let child = "function 世界() { return '\\0'; }"; + let parent = format!("/*{}\0*/{child}/*tail*/", "é".repeat(2200)); + let mut module = LlModule::new("aarch64-apple-darwin"); + let pool = SourcePool::emit(&mut module, [child, &parent, child].into_iter()); + let range = pool.get(child); + assert_eq!(range.global, pool.get(&parent).global); + assert_eq!(range.offset, parent.find(child).unwrap()); + assert_eq!(range.byte_len, child.len()); + assert_eq!( + module + .to_ir() + .lines() + .filter(|line| line.contains("private unnamed_addr constant")) + .count(), + 1 + ); + } + + #[test] + fn equal_parents_choose_first_occurrence() { + let result = verify(&[b"middle", b"[middle]", b"(middle)"], 0, 1024, 100); + assert_eq!(result, [(1, 1), (1, 0), (2, 0)]); + } +} diff --git a/crates/perry-codegen/src/codegen/string_pool.rs b/crates/perry-codegen/src/codegen/string_pool.rs index 96243477a1..760fe93e46 100644 --- a/crates/perry-codegen/src/codegen/string_pool.rs +++ b/crates/perry-codegen/src/codegen/string_pool.rs @@ -8,6 +8,7 @@ use crate::strings::StringPool; use crate::types::{DOUBLE, I32, I64, PTR, VOID}; use super::helpers::{sanitize, sanitize_member, scoped_static_method_name}; +use super::retained_source_pool::{SourcePool, SourceRange}; use super::spec_function_length; /// Emits a long sequence of INDEPENDENT init operations (string allocation, @@ -287,19 +288,43 @@ pub(super) fn emit_string_pool( user_fn_name_constants.push((wrapper_sym.clone(), const_name, byte_len)); } - // #4101: pre-allocate string constants for function-source registration, - // mirroring the name constants above (same borrow ordering: mint the - // rodata globals BEFORE `init_fn` claims `&mut llmod`). - let mut user_fn_source_constants: Vec<(String, String, usize, bool)> = Vec::new(); + // Collect class sources in registration order before preparing the shared + // source pool: a class can contain the exact bytes of a method/closure. + let mut class_sources: Vec<(u32, &String)> = Vec::new(); + for (class_name, class) in classes.iter() { + if *class_name != class.name || class_name.starts_with("__AnonShape_") { + continue; + } + let cid = match class_ids.get(class_name).copied() { + Some(c) if c != 0 => c, + _ => continue, + }; + if let Some(src) = class_source_text.get(&cid) { + class_sources.push((cid, src)); + } + } + class_sources.sort_by_key(|entry| entry.0); + class_sources.dedup_by_key(|(cid, _)| *cid); + + // #4101/#9413: mint source globals BEFORE `init_fn` borrows `llmod`. + // Sharing changes only the backing bytes, never registration order, source + // lengths, strictness flags, or the copying/static ownership contract. + let source_pool = SourcePool::emit( + llmod, + user_fn_source + .iter() + .filter(|(symbol, source, _)| !symbol.is_empty() && !source.is_empty()) + .map(|(_, source, _)| source.as_str()) + .chain(class_sources.iter().map(|(_, source)| source.as_str())), + ); + let mut user_fn_source_constants: Vec<(String, SourceRange, bool)> = Vec::new(); for (wrapper_sym, source_text, is_non_strict_ordinary) in user_fn_source { if wrapper_sym.is_empty() || source_text.is_empty() { continue; } - let (const_name, byte_len) = llmod.add_string_constant(source_text); user_fn_source_constants.push(( wrapper_sym.clone(), - const_name, - byte_len, + source_pool.get(source_text), *is_non_strict_ordinary, )); } @@ -338,28 +363,11 @@ pub(super) fn emit_string_pool( // #9413: the same pre-allocation for retained class source text — also // before `init_fn` borrows `llmod`. - let mut class_source_constants: Vec<(u32, String, usize)> = Vec::new(); - { - let mut sources: Vec<(u32, &String)> = Vec::new(); - for (class_name, class) in classes.iter() { - if *class_name != class.name || class_name.starts_with("__AnonShape_") { - continue; - } - let cid = match class_ids.get(class_name).copied() { - Some(c) if c != 0 => c, - _ => continue, - }; - if let Some(src) = class_source_text.get(&cid) { - sources.push((cid, src)); - } - } - sources.sort_by_key(|entry| entry.0); - sources.dedup_by_key(|(cid, _)| *cid); - for (cid, src) in sources { - let (const_name, byte_len) = llmod.add_string_constant(src); - class_source_constants.push((cid, const_name, byte_len)); - } - } + let class_source_constants: Vec<(u32, SourceRange)> = class_sources + .into_iter() + .map(|(cid, source)| (cid, source_pool.get(source))) + .collect(); + drop(source_pool); // Emit per-class typed-shape raw-f64 and pointer-mask globals. Empty masks // emit no storage. Must run BEFORE @@ -494,13 +502,12 @@ pub(super) fn emit_string_pool( // #4101: register each function's retained source text against the same // wrapper/closure address `js_closure_alloc_singleton` stamps into the // ClosureHeader, so `fn.toString()` resolves the source by func_ptr. - for (wrapper_sym, source_const, source_len, is_non_strict_ordinary) in &user_fn_source_constants - { + for (wrapper_sym, source, is_non_strict_ordinary) in &user_fn_source_constants { chunker.roll_if_full(); let blk = chunker.current_block(); let wrapper_ref = format!("@{}", wrapper_sym); - let source_ref = format!("@{}", source_const); - let len_str = source_len.to_string(); + let source_ref = source.pointer(blk); + let len_str = source.byte_len.to_string(); // Same spelling choice as the names above (#9188), and the bigger half // of the win: source text is registered for every function the bundle // CONTAINS, to serve a `Function.prototype.toString()` that most @@ -1199,16 +1206,16 @@ pub(super) fn emit_string_pool( // `Function.prototype.toString` on a class REF (an INT32 immediate, not a // ClosureHeader) answers with the class source. Same shape as the // `js_register_function_source_static` loop above. - for (cid, const_name, byte_len) in &class_source_constants { + for (cid, source) in &class_source_constants { chunker.roll_if_full(); let blk = chunker.current_block(); - let const_ref = format!("@{}", const_name); + let const_ref = source.pointer(blk); blk.call_void( "js_register_class_source", &[ (crate::types::I32, &cid.to_string()), (crate::types::PTR, &const_ref), - (crate::types::I32, &byte_len.to_string()), + (crate::types::I32, &source.byte_len.to_string()), ], ); } diff --git a/scripts/test-retained-source-pool.mjs b/scripts/test-retained-source-pool.mjs new file mode 100644 index 0000000000..112238cbc5 --- /dev/null +++ b/scripts/test-retained-source-pool.mjs @@ -0,0 +1,84 @@ +// Standalone native acceptance: exact Node reflection, live source sharing, +// and retained strings through GC, in default and compact codegen modes. +// Requires PERRY_BIN and a matching PERRY_RUNTIME_DIR; never builds Rust here. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const pin = fs.readFileSync(path.join(root, '.node-version'), 'utf8').trim().replace(/^v/, ''); +assert.equal(process.versions.node, pin, 'use the pinned Node oracle'); +assert(process.env.PERRY_BIN && process.env.PERRY_RUNTIME_DIR, 'explicit matched toolchain required'); +const raw = process.argv.includes('--expect-unshared'); +assert(process.argv.slice(2).every(arg => arg === '--expect-unshared'), 'unknown argument'); +const work = fs.mkdtempSync(path.join(os.tmpdir(), 'perry-retained-source-')); +const source = path.join(work, 'source.ts'); +fs.copyFileSync(path.join(root, 'test-files/test_gap_retained_source_pool.ts'), source); +const env = { ...process.env, PERRY_LL_OPT_LEVEL: 'z' }; +for (const key of ['PERRY_WORKSPACE_ROOT', 'PERRY_LIB_DIR', 'PERRY_RS4GC', 'PERRY_SHADOW_STACK', + 'PERRY_INLINE_SHADOW_SLOT', 'PERRY_FULL_OUTLINE_IC', 'PERRY_SAVE_LL']) delete env[key]; +const report = { work, expectedSharing: !raw, results: [] }; +function run(label, executable, args, cwd, overrides = {}, timeout = 15000) { + const result = spawnSync(executable, args, { cwd, env: { ...env, ...overrides }, + encoding: 'utf8', timeout, killSignal: 'SIGKILL', maxBuffer: 16 * 1024 * 1024 }); + fs.writeFileSync(path.join(work, label + '.stdout'), result.stdout ?? ''); + fs.writeFileSync(path.join(work, label + '.stderr'), result.stderr ?? ''); + assert(!result.error, `${label}: ${result.error}`); + assert.equal(result.status, 0, `${label}: ${result.stderr}`); + return result; +} +function llvmFiles(dir) { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const file = path.join(dir, entry.name); + return entry.isDirectory() ? llvmFiles(file) : entry.name.endsWith('.ll') ? [file] : []; + }); +} +console.log('Evidence: ' + work); +try { + const oracle = run('node', process.execPath, ['--expose-gc', source], work).stdout; + for (const mode of ['default', 'compact']) { + const cwd = path.join(work, mode); + fs.mkdirSync(cwd); + const output = path.join(cwd, 'app'); + const settings = mode === 'compact' ? { PERRY_RS4GC: '0', PERRY_SHADOW_STACK: '1', + PERRY_INLINE_SHADOW_SLOT: '0', PERRY_FULL_OUTLINE_IC: '1' } : {}; + run(mode + '-compile', env.PERRY_BIN, ['compile', source, '-o', output, + '--cache-dir', path.join(cwd, 'cache'), '--trace', 'llvm', '--no-auto-optimize', '--no-color', + ...(env.PERRY_TEST_WASM === '1' ? ['--platform', 'bun', '--enable-wasm-runtime'] : [])], + cwd, settings, 120000); + const candidates = llvmFiles(path.join(cwd, '.perry-trace/llvm')).map(file => fs.readFileSync(file, 'utf8')); + const ir = candidates.find(text => text.includes('RETAINED_SOURCE_PARENT') && + text.includes('call void @js_register_class_source(')); + assert(ir, 'fixture source registrations must actually be emitted'); + const sourceCalls = ir.split('\n').filter(line => line.includes('call void @js_register_function_source_static(')); + // Native LLVM construction can fold the GEP into a constant expression; + // the textual construction path leaves it as an SSA pointer operand. + const sharedCalls = sourceCalls.filter(line => /, ptr %[^,]+, i32 /.test(line) || + /, ptr getelementptr[^\n]*, i64 [1-9]\d*\)/.test(line)).length; + assert(sourceCalls.length >= 3, 'outer, inner and method registrations must be live'); + if (raw) assert.equal(sharedCalls, 0, 'baseline unexpectedly shares source ranges'); + else assert(sharedCalls >= 2, 'nested function and class method must use nonzero parent offsets'); + // Hide the input while executing; reflection must come from the image. + fs.renameSync(source, source + '.hidden'); + let actual; + try { + actual = run(mode + '-native', output, [], cwd, { ...settings, + PERRY_GC_SCHEDULE_SEED: '7', PERRY_GC_SCHEDULE_RATE: '0.05', PERRY_GC_SCHEDULE_ALLOC_KB: '0', + PERRY_GC_DIAG: '1', PERRY_GC_VERIFY_EVACUATION: '1', PERRY_GC_PROTECT_FROMSPACE: '1' }); + } finally { fs.renameSync(source + '.hidden', source); } + assert.equal(actual.stdout, oracle, 'native reflection differs from pinned Node'); + const moving = actual.stderr.match(/\[gc-schedule\] done:.*copying_minors=(\d+) moved_objects=(\d+) loop_polls=(\d+)/); + assert(moving && moving.slice(1).every(n => Number(n) > 0), 'moving GC must actually execute'); + const row = { mode, sharedCalls, executableBytes: fs.statSync(output).size, + copyingMinors: Number(moving[1]), movedObjects: Number(moving[2]), loopPolls: Number(moving[3]) }; + report.results.push(row); + console.log('PASS ' + JSON.stringify(row)); + } + report.passed = true; +} catch (error) { + report.passed = false; report.error = String(error); process.exitCode = 1; console.error(error); +} finally { + fs.writeFileSync(path.join(work, 'result.json'), JSON.stringify(report, null, 2) + '\n'); +} diff --git a/test-files/test_gap_retained_source_pool.ts b/test-files/test_gap_retained_source_pool.ts new file mode 100644 index 0000000000..1c3a0c5bd2 --- /dev/null +++ b/test-files/test_gap_retained_source_pool.ts @@ -0,0 +1,28 @@ +// Independent source-pooling/reflection regression; no bundled application. +function outer() { + /* RETAINED_SOURCE_PARENT source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界-source-世界- */ + function inner() { return "héllø \\0 世界"; } + return inner; +} +class Envelope { + method() { + /* RETAINED_CLASS_PARENT class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é-class-é- */ + return "method-value"; + } +} +const nested = outer(); +const instance = new Envelope(); +const heldOuter = outer.toString(); +const heldInner = nested.toString(); +const heldClass = Envelope.toString(); +const heldMethod = instance.method.toString(); +console.log(nested(), instance.method()); +console.log(JSON.stringify([heldOuter, heldInner, heldClass, heldMethod])); +for (let i = 0; i < 4; i++) { + const churn: any[] = []; + for (let j = 0; j < 1000; j++) churn.push({ index: j, text: "kept-" + j }); + if (typeof (globalThis as any).gc === "function") (globalThis as any).gc(); + console.log(churn[999].text); +} +console.log(heldOuter === outer.toString(), heldInner === nested.toString(), + heldClass === Envelope.toString(), heldMethod === instance.method.toString()); From bf7f87e61cc54878c22dc809421c372dffd6c80e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 12:26:32 +0200 Subject: [PATCH 2/2] Key the retained source sharing changeset to PR 10065 --- ...d-source-range-pool.md => 10065-retained-source-range-pool.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{pending-retained-source-range-pool.md => 10065-retained-source-range-pool.md} (100%) diff --git a/changelog.d/pending-retained-source-range-pool.md b/changelog.d/10065-retained-source-range-pool.md similarity index 100% rename from changelog.d/pending-retained-source-range-pool.md rename to changelog.d/10065-retained-source-range-pool.md