diff --git a/crates/oxide_vm/src/session_gc.rs b/crates/oxide_vm/src/session_gc.rs index 59af496..37b4ccc 100644 --- a/crates/oxide_vm/src/session_gc.rs +++ b/crates/oxide_vm/src/session_gc.rs @@ -534,6 +534,56 @@ impl SessionGc { } } + /// 仅回收 session 字符串:完整 mark(对象只置位不搬移)→ 按存活集清扫字符串。 + /// + /// 刻意跳过对象 sweep 搬移——builtin/dispatch 层持有跨分配点的 session 对象 + /// 裸指针,移动式 sweep 会使其悬垂;对象执行期回收仍只在 reset 统一进行。 + /// 字符串每串独立 Box、地址稳定,清扫无需 forwarding 与根重写。 + /// + /// # 副作用 + /// - 清空全部对象 mark 位;按存活集释放死串;累计 `total_collections` 与时长统计。 + /// + /// # 注意事项 + /// - mark 的 DFS 以 `is_gc_marked` 短路,残留 true 会导致后续完整收集漏标, + /// 本路径不跑对象 sweep(sweep 内清位不会执行),必须先 `clear_all_marks`。 + /// - 不搬移对象,`session_bytes_allocated` 保持对象账目,手动扣掉字符串账目后 + /// 由 `sweep_session_strings` 补回存活串字节,与完整收集的最终账目一致。 + pub(crate) fn collect_strings_only(&mut self, vm: &mut Vm) { + let start = Instant::now(); + + vm_info!("[GC] strings-only cycle #{} start", self.total_collections + 1); + + // 清残留 mark 位:防止本次字符串 mark 因历史 true 短路而漏标。 + self.clear_all_marks(vm); + + // 完整 mark:复用现有实现,产出 live_strings;对象仅置位、不搬移。 + self.mark(vm); + + // 扣减字符串账目(保留对象账目),再补回存活串字节。 + let object_bytes = vm.gc_state.session_object_ptrs.len() * size_of::(); + vm.gc_state.session_bytes_allocated = object_bytes; + let freed_bytes = self.sweep_session_strings(vm); + + let elapsed = start.elapsed(); + self.total_collections += 1; + self.last_collection_duration_us = elapsed.as_micros() as u64; + self.max_collection_duration_us = self.max_collection_duration_us.max(self.last_collection_duration_us); + self.min_collection_duration_us = self.min_collection_duration_us.min(self.last_collection_duration_us); + + vm_info!( + "[GC] strings-only cycle #{} end: {} bytes freed, {:.1}ms", + self.total_collections, + freed_bytes, + elapsed.as_secs_f64() * 1000.0, + ); + } + + pub(crate) fn maybe_collect_strings_only(&mut self, vm: &mut Vm) { + if self.should_collect(vm) { + self.collect_strings_only(vm); + } + } + pub(crate) fn maybe_collect(&mut self, vm: &mut Vm) { if self.should_collect(vm) { self.collect(vm); @@ -647,7 +697,8 @@ mod tests { } #[test] - fn gc_roots_contains_registers_frames_and_root_roots() { let mut vm = Vm::new(); + fn gc_roots_contains_registers_frames_and_root_roots() { + let mut vm = Vm::new(); let root = plain_object(&mut vm); let frame_obj = plain_object(&mut vm); let saved_this = plain_object(&mut vm); @@ -746,7 +797,8 @@ mod tests { } #[test] - fn mark_phase_reaches_cycles_and_unreachable_are_unmarked() { let mut vm = Vm::new(); + fn mark_phase_reaches_cycles_and_unreachable_are_unmarked() { + let mut vm = Vm::new(); let root = plain_object(&mut vm); let reachable = plain_object(&mut vm); let unreachable = plain_object(&mut vm); @@ -1201,4 +1253,137 @@ mod tests { assert!(after >= before + expected); } + + fn vm_with_threshold(bytes: usize) -> Vm { + let mut cfg = KernelConfig::minimal(); + cfg.set_session_gc_threshold(bytes); + let core = KernelCore::new(cfg); + Vm::with_kernel_core(core) + } + + #[test] + fn runtime_threshold_triggers_strings_only_collection() { + let mut vm = vm_with_threshold(1); + // 第一次分配不触发(累计 0 < 阈值),登记后超阈值;后续每次分配前先回收。 + 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!(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)); + } + + #[test] + fn strings_only_collection_preserves_all_root_kinds() { + // 阈值 1:每次 new_string_owned 分配前自动触发回收,逐步验证各类根的保护。 + let mut vm = vm_with_threshold(1); + // 寄存器根:直接持有 session 串。 + let reg_str = vm.new_string_owned("reg-root".repeat(16)); + vm.regs[0] = reg_str; + // 存活对象属性根:session 对象经 promote 后持有 session 串(分配即触发回收, + // reg_str 仍在寄存器,prop_str 挂到对象后才被下次回收看到)。 + let obj = plain_object(&mut vm); + let prop_str = vm.new_string_owned("prop-root".repeat(16)); + unsafe { + (*obj).set_prop_at(0, prop_str); + } + let obj_session = vm.promote_object(obj); + vm.regs[1] = JsValue::from_js_object(obj_session); + // 非 session 根对象属性:epoch 根对象(在寄存器)持有 session 串,mark 走 + // 非 session 根扫描路径保护。 + let epoch_obj = plain_object(&mut vm); + let epoch_str = vm.new_string_owned("epoch-root".repeat(16)); + unsafe { + (*epoch_obj).set_prop_at(0, epoch_str); + } + vm.regs[2] = JsValue::from_js_object(epoch_obj); + // 死串:无任何根引用,最后手动触发一轮回收它。 + let dead = vm.new_string_owned("dead".repeat(16)); + let dead_ptr = dead.as_string_ptr_mut(); + + vm.maybe_collect_session_strings(); + + assert!(vm.gc_state.session_string_ptrs.contains(®_str.as_string_ptr_mut())); + assert!(vm.gc_state.session_string_ptrs.contains(&prop_str.as_string_ptr_mut())); + assert!(vm.gc_state.session_string_ptrs.contains(&epoch_str.as_string_ptr_mut())); + assert!(!vm.gc_state.session_string_ptrs.contains(&dead_ptr)); + // 存活串内容可读,地址稳定。 + assert_eq!(unsafe { (*reg_str.as_string_ptr_mut()).as_str() }, "reg-root".repeat(16)); + assert_eq!(unsafe { (*prop_str.as_string_ptr_mut()).as_str() }, "prop-root".repeat(16)); + assert_eq!(unsafe { (*epoch_str.as_string_ptr_mut()).as_str() }, "epoch-root".repeat(16)); + } + + #[test] + fn strings_only_collection_does_not_move_objects() { + let mut vm = vm_with_threshold(1); + let obj = plain_object(&mut vm); + let obj_session = vm.promote_object(obj); + vm.regs[0] = JsValue::from_js_object(obj_session); + let before: Vec<_> = vm.gc_state.session_object_ptrs.clone(); + + let s = vm.new_string_owned("x".repeat(64)); + vm.regs[1] = s; + + // 对象指针逐一相同:不搬移、不重写根。 + assert_eq!(vm.gc_state.session_object_ptrs, before); + assert_eq!(vm.regs[0].as_js_object_ptr(), obj_session); + assert_eq!(vm.session_object_count(), 1); + } + + #[test] + fn multiple_strings_only_cycles_keep_objects_alive() { + let mut vm = vm_with_threshold(1); + let obj = plain_object(&mut vm); + let obj_session = vm.promote_object(obj); + let live = vm.new_string_owned("keep".repeat(32)); + unsafe { + (*obj_session).set_prop_at(0, live); + } + vm.regs[0] = JsValue::from_js_object(obj_session); + + // 连续多轮触发字符串回收:对象 mark 残留位被显式清理,对象与挂载串持续存活。 + for _ in 0..5 { + for _ in 0..8 { + let _ = vm.new_string_owned("dead".repeat(64)); + } + assert!(vm.gc_state.session_object_ptrs.contains(&obj_session)); + assert_eq!(unsafe { (*obj_session).get_prop_at(0) }, live); + } + assert!(vm.gc_state.session_string_ptrs.contains(&live.as_string_ptr_mut())); + } + + #[test] + fn strings_only_collection_byte_accounting_matches_survivors() { + let mut vm = vm_with_threshold(1); + let obj = plain_object(&mut vm); + let obj_session = vm.promote_object(obj); + vm.regs[0] = JsValue::from_js_object(obj_session); + let live = vm.new_string_owned("live".repeat(8)); + let live_ptr = live.as_string_ptr_mut(); + vm.regs[1] = live; + let dead = vm.new_string_owned("dead".repeat(8)); + let _ = dead.as_string_ptr_mut(); + + vm.maybe_collect_session_strings(); + + // 账目 = 对象头 + 存活串(size_of::() + len),死串不再计入。 + let expected = size_of::() + (size_of::() + unsafe { (*live_ptr).len() }); + assert_eq!(vm.gc_state.session_bytes_allocated, expected); + assert!(!vm.gc_state.session_string_ptrs.contains(&dead.as_string_ptr_mut())); + } + + #[test] + fn runtime_collection_below_threshold_is_noop() { + let mut vm = Vm::new(); + let s = vm.new_string_owned("small".repeat(4)); + vm.regs[0] = s; + + assert_eq!(vm.gc_state.session_gc.total_collections, 0); + assert!(vm.gc_state.session_string_ptrs.contains(&s.as_string_ptr_mut())); + } } diff --git a/crates/oxide_vm/src/vm.rs b/crates/oxide_vm/src/vm.rs index fbfa12f..667d1d7 100644 --- a/crates/oxide_vm/src/vm.rs +++ b/crates/oxide_vm/src/vm.rs @@ -804,6 +804,14 @@ impl Vm { self.gc_state.session_gc = session_gc; } + /// 执行期字符串阈值回收:仅回收 session 字符串(跳过对象搬移)。热路径只在 + /// 超阈值后进入,`mem::take` 不承担每次分配的开销。 + pub(crate) fn maybe_collect_session_strings(&mut self) { + let mut session_gc = std::mem::take(&mut self.gc_state.session_gc); + session_gc.maybe_collect_strings_only(self); + self.gc_state.session_gc = session_gc; + } + /// 只读访问 session GC 的统计(回收次数、存活/死亡对象数、释放字节等)。 pub fn session_gc_stats(&self) -> &SessionGc { &self.gc_state.session_gc diff --git a/crates/oxide_vm/src/vm_support.rs b/crates/oxide_vm/src/vm_support.rs index 6cd60e3..c7249a9 100644 --- a/crates/oxide_vm/src/vm_support.rs +++ b/crates/oxide_vm/src/vm_support.rs @@ -363,8 +363,19 @@ impl Vm { } /// 同 `new_string`,但以 move 接收 `String`,避免一次克隆。 + /// + /// # 副作用 + /// - 累计 session 字符串字节超阈值时,触发一次仅字符串的 GC(对象不搬移)。 + /// + /// # 注意事项 + /// - 触发检查在登记之前:返回值尚未写入任何执行根,若登记后回收会被本次 GC + /// 判死并释放,返回即悬垂;此时 `s` 仍在栈上、未被登记,GC 不会触碰它。 + /// - 热路径仅 1 次 usize 比较 + 阈值读取,超限才进回收路径。 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 new file mode 100644 index 0000000..1233294 --- /dev/null +++ b/crates/oxide_vm/tests/gc_runtime_tests.rs @@ -0,0 +1,81 @@ +use oxide_compiler::compiler::Compiler; +use oxide_kernel::kernel::{KernelConfig, KernelCore}; +use oxide_parser::Allocator; +use oxide_vm::vm::Vm; + +fn compile(source: &str) -> oxide_bytecode::module::CompiledModule { + let allocator = Allocator::default(); + let program = oxide_parser::parse(&allocator, source).expect("parse"); + Compiler::new().compile(&program).expect("compile") +} + +fn vm_with_threshold(bytes: usize) -> Vm { + let mut config = KernelConfig::minimal(); + config.set_session_gc_threshold(bytes); + Vm::with_kernel_core(KernelCore::new(config)) +} + +/// 长循环拼接产生海量 session 字符串:执行期自动触发仅字符串 GC, +/// 结果正确且 session 字节有界(不触发则累计 ~20 MiB 远超低阈值)。 +#[test] +fn long_string_loop_triggers_runtime_gc_and_bounds_session() { + let mut vm = vm_with_threshold(4096); + let module = compile("var s; for (var i = 0; i < 500000; i++) { s = 'str' + i; } s.length"); + let result = vm.run(&module).expect("run"); + + assert_eq!(format!("{}", result), "9"); + assert!(vm.session_gc_stats().total_collections > 0, "执行期应触发字符串 GC"); + assert!( + vm.session_bytes_allocated() < 1_000_000, + "session 字节应受阈值约束,实际 {}", + vm.session_bytes_allocated() + ); +} + +/// 拼接结果写 global(逃逸根),跨 run 后触发执行期 GC 仍可读—— +/// 验证非会话存活串不因执行期回收丢失。 +#[test] +fn session_string_survives_across_runs_with_runtime_gc() { + let mut vm = vm_with_threshold(4096); + let first = compile("globalThis.kept = 'he' + 'llo'; 0"); + vm.run(&first).expect("run1"); + + let second = compile("var s; for (var i = 0; i < 500000; i++) { s = 'y' + i; } globalThis.kept"); + let result = vm.run(&second).expect("run2"); + let text = vm.lookup_str(result).expect("kept 应为字符串").to_string(); + + assert_eq!(text, "hello"); + assert!(vm.session_gc_stats().total_collections > 0, "run2 应触发执行期字符串 GC"); +} + +/// reset(轻量重置)保留 session 字符串:跨 eval 存活语义不被执行期 GC 破坏。 +#[test] +fn reset_preserves_session_strings_after_runtime_gc() { + let mut vm = vm_with_threshold(4096); + let first = compile("globalThis.kept = 'x' + 'y'; 0"); + vm.run(&first).expect("run1"); + vm.reset(); + + let second = compile("globalThis.kept"); + let result = vm.run(&second).expect("run2"); + let text = vm.lookup_str(result).expect("kept 应为字符串").to_string(); + assert_eq!(text, "xy"); +} + +/// full_reset 清空 session 内存:执行期回收释放过的死串不与完全重置的 +/// 整体释放路径重复释放(无双重释放崩溃),重置后引擎可继续运行。 +#[test] +fn full_reset_clears_session_after_runtime_gc() { + let mut vm = vm_with_threshold(4096); + let first = compile("var s; for (var i = 0; i < 200000; i++) { s = 'a' + i; } s.length"); + vm.run(&first).expect("run1"); + assert!(vm.session_gc_stats().total_collections > 0, "run1 应触发执行期字符串 GC"); + + vm.full_reset(); + + assert_eq!(vm.session_bytes_allocated(), 0, "full_reset 后 session 字节应为 0"); + let second = compile("'ok' + '!'"); + let result = vm.run(&second).expect("run2"); + let text = vm.lookup_str(result).expect("拼接结果应为字符串").to_string(); + assert_eq!(text, "ok!"); +} diff --git a/tests/stress/gc_string_long.js b/tests/stress/gc_string_long.js new file mode 100644 index 0000000..ddabe0e --- /dev/null +++ b/tests/stress/gc_string_long.js @@ -0,0 +1,8 @@ +var ITERATIONS = 1000000; + +var sum = 0; +for (var i = 0; i < ITERATIONS; i++) { + var s = "str" + i; + sum += s.length; +} +sum