diff --git a/crates/oxide_vm/src/session_gc.rs b/crates/oxide_vm/src/session_gc.rs index 79f39ade..47b91576 100644 --- a/crates/oxide_vm/src/session_gc.rs +++ b/crates/oxide_vm/src/session_gc.rs @@ -504,6 +504,40 @@ impl SessionGc { && vm.gc_state.session_bytes_allocated >= vm.kernel_core().config().session_gc_threshold } + /// 执行期字符串回收的触发判断:账目须超过水位(上次收集后的存活字节 + + /// 阈值增量)。完整收集(reset)仍用 [`Self::should_collect`] 的阈值直接比较, + /// 保证死对象超阈值即被 reset 回收;执行期走增量水位,活串超阈值时不每指令 + /// 重复触发无死串可回收的白跑。 + pub(crate) fn should_collect_strings(&self, vm: &Vm) -> bool { + (!vm.gc_state.session_object_ptrs.is_empty() || !vm.gc_state.session_string_ptrs.is_empty()) + && vm.gc_state.session_bytes_allocated >= vm.gc_state.string_gc_watermark + } + + /// debug 兜底:mark 之后断言所有存活对象持有的字符串边都已登记进 + /// `live_strings`——捕获"对象边漏登记 → 存活串被误释放"的静默悬垂。 + #[cfg(debug_assertions)] + fn debug_assert_marked_object_strings_live(&self, vm: &Vm) { + let mut live = HashSet::with_hasher(FxBuildHasher); + for &ptr in &vm.gc_state.session_object_ptrs { + if ptr.is_null() { + continue; + } + // SAFETY: session_object_ptrs 归 VM 所有,指针在 arena 存活期有效。 + let obj = unsafe { &*ptr }; + if !obj.is_gc_marked() { + continue; + } + live.clear(); + Self::record_object_string_edges(&mut live, obj); + for s_ptr in live.drain() { + assert!( + self.live_strings.contains(&s_ptr), + "存活对象持有未登记的 session 字符串边" + ); + } + } + } + pub(crate) fn collect(&mut self, vm: &mut Vm) { let start = Instant::now(); @@ -541,11 +575,13 @@ impl SessionGc { /// 字符串每串独立 Box、地址稳定,清扫无需 forwarding 与根重写。 /// /// # 副作用 - /// - 清空全部对象 mark 位;按存活集释放死串;累计 `total_collections` 与时长统计。 + /// - 清空全部对象 mark 位;按存活集释放死串;累计 `total_collections` 与时长统计; + /// 抬高 `string_gc_watermark`(存活字节 + 阈值增量)供下次执行期触发。 /// /// # 注意事项 /// - mark 的 DFS 以 `is_gc_marked` 短路,残留 true 会导致后续完整收集漏标, - /// 本路径不跑对象 sweep(sweep 内清位不会执行),必须先 `clear_all_marks`。 + /// 本路径不跑对象 sweep(sweep 内清位不会执行),故开头与结尾都清位—— + /// 维持"mark 之后必由清位收尾"的不变量,strings-only 结束不留残留。 /// - 不搬移对象,`session_bytes_allocated` 保持对象账目,手动扣掉字符串账目后 /// 由 `sweep_session_strings` 补回存活串字节,与完整收集的最终账目一致。 pub(crate) fn collect_strings_only(&mut self, vm: &mut Vm) { @@ -559,6 +595,9 @@ impl SessionGc { // 完整 mark:复用现有实现,产出 live_strings;对象仅置位、不搬移。 self.mark(vm); + // debug 兜底:存活对象的字符串边必须已登记,否则串清扫将释放活串。 + self.debug_assert_marked_object_strings_live(vm); + // 扣减字符串账目(保留对象账目),再补回存活串字节。 let object_bytes = vm.gc_state.session_object_ptrs.len() * size_of::(); vm.gc_state.session_bytes_allocated = object_bytes; @@ -569,6 +608,11 @@ impl SessionGc { // 其字符串边不进 live_strings → 存活串被误释放 → 存活对象持悬垂指针。 self.clear_all_marks(vm); + // 抬高下次触发水位:本次存活字节 + 阈值增量,避免活串超阈值时每指令重复 + // 触发无死串可回收的完整 mark + 串清扫。 + let threshold = vm.kernel_core().config().session_gc_threshold; + vm.gc_state.string_gc_watermark = vm.gc_state.session_bytes_allocated.saturating_add(threshold); + let elapsed = start.elapsed(); self.total_collections += 1; self.last_collection_duration_us = elapsed.as_micros() as u64; @@ -584,7 +628,7 @@ impl SessionGc { } pub(crate) fn maybe_collect_strings_only(&mut self, vm: &mut Vm) { - if self.should_collect(vm) { + if self.should_collect_strings(vm) { self.collect_strings_only(vm); } } @@ -1267,20 +1311,23 @@ mod tests { } #[test] - fn runtime_threshold_triggers_strings_only_collection() { + fn allocation_does_not_trigger_gc_before_safe_point() { let mut vm = vm_with_threshold(1); - // 第一次分配不触发(累计 0 < 阈值),登记后超阈值;后续每次分配前先回收。 + // 分配点不触发回收:触发已移到 dispatch 指令边界,局部持有跨分配点安全。 let seed = vm.new_string_owned("seed".repeat(16)); let seed_ptr = seed.as_string_ptr_mut(); - assert!(vm.gc_state.session_string_ptrs.contains(&seed_ptr)); - - // 分配前触发回收:seed 无根引用 → 判死释放,返回值本身存活(登记前 GC 不触碰它)。 let returned = vm.new_string_owned("returned".repeat(16)); - let returned_ptr = returned.as_string_ptr_mut(); + assert_eq!(vm.gc_state.session_gc.total_collections, 0); + assert!(vm.gc_state.session_string_ptrs.contains(&seed_ptr)); + assert!(vm.gc_state.session_string_ptrs.contains(&returned.as_string_ptr_mut())); + + // 显式触发后:无根引用的 seed 被回收,入根的 returned 存活(安全点语义)。 + vm.regs[0] = returned; + vm.maybe_collect_session_strings(); assert!(vm.gc_state.session_gc.total_collections >= 1); assert!(!vm.gc_state.session_string_ptrs.contains(&seed_ptr)); - assert!(vm.gc_state.session_string_ptrs.contains(&returned_ptr)); + assert!(vm.gc_state.session_string_ptrs.contains(&returned.as_string_ptr_mut())); } #[test] @@ -1333,6 +1380,8 @@ mod tests { let s = vm.new_string_owned("x".repeat(64)); vm.regs[1] = s; + // 分配不自动触发(安全点语义),显式触发一轮验证对象不搬移。 + vm.maybe_collect_session_strings(); // 对象指针逐一相同:不搬移、不重写根。 assert_eq!(vm.gc_state.session_object_ptrs, before); @@ -1356,6 +1405,8 @@ mod tests { for _ in 0..8 { let _ = vm.new_string_owned("dead".repeat(64)); } + // 分配不自动触发,每轮显式触发一次回收。 + vm.maybe_collect_session_strings(); assert!(vm.gc_state.session_object_ptrs.contains(&obj_session)); assert_eq!(unsafe { (*obj_session).get_prop_at(0) }, live); } diff --git a/crates/oxide_vm/src/vm.rs b/crates/oxide_vm/src/vm.rs index d8490d00..e48cf797 100644 --- a/crates/oxide_vm/src/vm.rs +++ b/crates/oxide_vm/src/vm.rs @@ -1320,6 +1320,12 @@ impl Vm { let mut steps: u64 = 0; loop { steps += 1; + // 执行期字符串 GC 安全点:指令边界上无 builtin 局部活值(分配点触发 + // 会释放仅存于 Rust 局部/构造中未登记对象的串),此处触发保证回收 + // 时所有存活串都已落地为执行根。账目未超水位时仅 2 次字段比较。 + if self.gc_state.session_bytes_allocated >= self.gc_state.string_gc_watermark { + self.maybe_collect_session_strings(); + } if let Some(max_steps) = max_steps { if steps > max_steps { vm_warn!("dispatch: step limit {} exceeded at pc={}", max_steps, self.pc); diff --git a/crates/oxide_vm/src/vm_state.rs b/crates/oxide_vm/src/vm_state.rs index b27508e9..994fc1a0 100644 --- a/crates/oxide_vm/src/vm_state.rs +++ b/crates/oxide_vm/src/vm_state.rs @@ -34,6 +34,10 @@ pub(crate) struct GcState { /// mark/sweep 回收(值量少),只在 full_reset 统一释放。 pub(crate) session_bigint_ptrs: RefCell>, pub(crate) session_bytes_allocated: usize, + /// 执行期字符串 GC 的触发水位:本次收集后的存活字节 + 阈值增量。 + /// 仅当账目超过水位才在指令边界触发回收——活串超阈值时不会每指令重复 + /// 触发无死串可回收的白跑,且保证触发点恒在无 builtin 局部活值的边界。 + pub(crate) string_gc_watermark: usize, pub(crate) forwarding: HashMap<*mut JsObject, *mut JsObject, FxBuildHasher>, } diff --git a/crates/oxide_vm/src/vm_support.rs b/crates/oxide_vm/src/vm_support.rs index c7249a9e..e584e887 100644 --- a/crates/oxide_vm/src/vm_support.rs +++ b/crates/oxide_vm/src/vm_support.rs @@ -25,6 +25,8 @@ impl Vm { // 非 0 的稳定 id(保持枚举层"id 0 哨兵"的既有约定,见 walk_own_keys)。 let length_si = core.perm_interner().intern("length").0; let obj_proto = P::clone(&session.builtin_world().object_proto); + // 提前缓存执行期字符串 GC 初始水位(构造后 config 不再变化)。 + let gc_threshold = core.config().session_gc_threshold; let mut vm = Self { regs: [JsValue::undefined(); 256], pc: 0, @@ -86,6 +88,7 @@ impl Vm { session_string_ptrs: Vec::new(), session_bigint_ptrs: std::cell::RefCell::new(Vec::new()), session_bytes_allocated: 0, + string_gc_watermark: gc_threshold, forwarding: std::collections::HashMap::with_hasher(rustc_hash::FxBuildHasher), }, symbols: SymbolState { @@ -123,6 +126,8 @@ impl Vm { // 非 0 的稳定 id(保持枚举层"id 0 哨兵"的既有约定,见 walk_own_keys)。 let length_si = core.perm_interner().intern("length").0; let obj_proto = P::clone(&session.builtin_world().object_proto); + // 提前缓存执行期字符串 GC 初始水位(构造后 config 不再变化)。 + let gc_threshold = core.config().session_gc_threshold; let mut vm = Self { regs: [JsValue::undefined(); 256], pc: 0, @@ -184,6 +189,7 @@ impl Vm { session_string_ptrs: Vec::new(), session_bigint_ptrs: std::cell::RefCell::new(Vec::new()), session_bytes_allocated: 0, + string_gc_watermark: gc_threshold, forwarding: std::collections::HashMap::with_hasher(rustc_hash::FxBuildHasher), }, symbols: SymbolState { @@ -285,6 +291,7 @@ impl Vm { self.gc_state.session_epoch.reset(); self.gc_state.session_object_ptrs.clear(); self.gc_state.session_bytes_allocated = 0; + self.gc_state.string_gc_watermark = self.kernel_core.config().session_gc_threshold; self.gc_state.session_gc = crate::session_gc::SessionGc::new(); self.free_session_string_heap_data(); self.free_session_bigint_heap_data(); @@ -365,17 +372,16 @@ impl Vm { /// 同 `new_string`,但以 move 接收 `String`,避免一次克隆。 /// /// # 副作用 - /// - 累计 session 字符串字节超阈值时,触发一次仅字符串的 GC(对象不搬移)。 + /// - 累计 session 字符串字节账目;回收不在分配点触发(见注意事项)。 /// /// # 注意事项 - /// - 触发检查在登记之前:返回值尚未写入任何执行根,若登记后回收会被本次 GC - /// 判死并释放,返回即悬垂;此时 `s` 仍在栈上、未被登记,GC 不会触碰它。 - /// - 热路径仅 1 次 usize 比较 + 阈值读取,超限才进回收路径。 + /// - 分配点不触发 GC:builtin 函数栈上的局部 `JsValue` 与构造中对象不在 + /// mark 根清单内,分配前触发会释放"仅存于局部/构造中"的活串(悬垂)。 + /// 回收统一在 dispatch 指令边界检查水位触发——此时 builtin 局部值已落地 + /// 为执行根,任何分配点的局部持有跨分配点均安全。 + /// - 热路径仅 1 次字节账目累加,无阈值读取与分支。 pub fn new_string_owned(&mut self, s: String) -> JsValue { let len = s.len(); - if self.gc_state.session_bytes_allocated >= self.kernel_core.config().session_gc_threshold { - self.maybe_collect_session_strings(); - } let ptr = Box::into_raw(Box::new(JsString::new(s))); self.gc_state.session_string_ptrs.push(ptr); self.gc_state.session_bytes_allocated += std::mem::size_of::() + len; diff --git a/crates/oxide_vm/tests/gc_runtime_tests.rs b/crates/oxide_vm/tests/gc_runtime_tests.rs index 83329485..caf3bce7 100644 --- a/crates/oxide_vm/tests/gc_runtime_tests.rs +++ b/crates/oxide_vm/tests/gc_runtime_tests.rs @@ -80,6 +80,58 @@ fn strings_only_then_reset_keeps_global_subtree_strings() { assert_eq!(text, "hello"); } +/// 低阈值执行期字符串 GC 下 builtin 构造期局部串跨分配点安全(触发点 +/// 在指令边界,native 调用内不回收):RegExp exec 写入构造中数组的匹配串/ +/// 捕获组串不被后续分配回收,返回数组内容正确。 +#[test] +fn regexp_exec_strings_survive_runtime_gc() { + let mut vm = vm_with_threshold(512); + let module = compile( + "var text = 'a'.repeat(500) + 'b'.repeat(500); var m = /(a+)(b+)/.exec(text); \ + m[0] + '|' + m[1] + '|' + m[2]", + ); + let result = vm.run(&module).expect("run"); + let text = vm.lookup_str(result).expect("exec 结果应为字符串").to_string(); + let expected = format!( + "{}|{}|{}", + "a".repeat(500) + &"b".repeat(500), + "a".repeat(500), + "b".repeat(500) + ); + assert_eq!(text, expected); + assert!(vm.session_gc_stats().total_collections > 0, "执行期应触发字符串 GC"); +} + +/// 低阈值下 Temporal.ZonedDateTime 构造的局部串(timeZone/calendar 槽)跨 +/// 分配安全:对象槽不因执行期回收而悬垂,getter 返回原内容。 +#[test] +fn temporal_zoned_date_time_strings_survive_runtime_gc() { + let mut vm = vm_with_threshold(512); + let module = compile( + "var g = {}; for (var i = 0; i < 20; i++) { g['k' + i] = 'v'.repeat(64); } \ + var z = new Temporal.ZonedDateTime(0n, 'UTC'); z.timeZoneId + '|' + z.calendarId", + ); + let result = vm.run(&module).expect("run"); + let text = vm.lookup_str(result).expect("ZDT 槽应为字符串").to_string(); + assert_eq!(text, "UTC|iso8601"); + assert!(vm.session_gc_stats().total_collections > 0, "执行期应触发字符串 GC"); +} + +/// 低阈值下 replace 函数 replacer 的回调参数 Vec(局部逐项分配)跨分配安全: +/// 回调内拼接正确,说明参数串未被执行期回收释放。 +#[test] +fn replace_replacer_args_survive_runtime_gc() { + let mut vm = vm_with_threshold(512); + let module = compile( + "var g = {}; for (var i = 0; i < 20; i++) { g['k' + i] = 'v'.repeat(64); } \ + 'a-b-c'.replace(/(a)-(b)-(c)/g, function(m, p1, p2, p3, pos, s) { return p1 + p2 + p3; })", + ); + let result = vm.run(&module).expect("run"); + let text = vm.lookup_str(result).expect("replace 结果应为字符串").to_string(); + assert_eq!(text, "abc"); + assert!(vm.session_gc_stats().total_collections > 0, "执行期应触发字符串 GC"); +} + /// full_reset 清空 session 内存:执行期回收释放过的死串不与完全重置的 /// 整体释放路径重复释放(无双重释放崩溃),重置后引擎可继续运行。 #[test]