diff --git a/crates/oxide_test262/src/config.rs b/crates/oxide_test262/src/config.rs index c9eabcd4..b931d2d1 100644 --- a/crates/oxide_test262/src/config.rs +++ b/crates/oxide_test262/src/config.rs @@ -102,6 +102,7 @@ impl RunConfig { \x20 OXIDE_TEST262_SUPERVISORS concurrent supervised windows (default 16)\n\ \x20 OXIDE_TEST262_RUNNING_LOG log every test as it starts (any value)\n\ \x20 OXIDE_TEST262_HEARTBEAT heartbeat file path (supervised/chunked)\n\ + \x20 OXIDE_TEST262_PC_WATCH last-pc scene file path (supervised child appends pc/opcode/frames every 65536 instructions)\n\ \x20 OXIDE_TEST262_CHUNK_SIZE tests per child in chunked mode\n\ \x20 OXIDE_TEST262_CHILD_CHUNK marks a spawned child as chunk worker (any value)\n\ \x20 OXIDE_TEST262_ALLOW_FAIL_EXIT child may exit 1 on fail (any value)\n\ diff --git a/crates/oxide_test262/src/runner.rs b/crates/oxide_test262/src/runner.rs index 61442e97..5b65ba87 100644 --- a/crates/oxide_test262/src/runner.rs +++ b/crates/oxide_test262/src/runner.rs @@ -15,7 +15,7 @@ use oxide_kernel::kernel::{KernelConfig, KernelCore}; use oxide_types::value::JsValue; use oxide_vm::vm::Vm; use std::path::{Path, PathBuf}; -use std::sync::{Arc, RwLock}; +use std::sync::{Arc, OnceLock, RwLock}; // 记录当前正在执行的测试路径(thread-local);每个测试执行前写入, // panic hook 据此定位崩溃所在的测试文件。 @@ -23,6 +23,17 @@ std::thread_local! { pub(crate) static CURRENT_TEST_PATH: std::cell::RefCell = const { std::cell::RefCell::new(String::new()) }; } +/// 监督者经环境变量注入的 last-pc 现场文件路径(子进程单实例,每进程读一次): +/// 子进程运行期向该文件定频写现场行,监督者超时/崩溃杀子进程后读回末行。 +static PC_WATCH_PATH: OnceLock> = OnceLock::new(); + +/// 读取监督者注入的 last-pc 现场文件路径(未注入返回 None)。 +fn pc_watch_path() -> Option<&'static PathBuf> { + PC_WATCH_PATH + .get_or_init(|| std::env::var_os("OXIDE_TEST262_PC_WATCH").map(PathBuf::from)) + .as_ref() +} + /// 在 catch_unwind 保护下运行单个测试,把引擎 panic 记为失败。 #[expect(clippy::too_many_arguments)] fn run_test( @@ -160,6 +171,10 @@ fn run_test_inner( }; let mut vm = Vm::with_kernel_core(Arc::clone(kernel)); + // 监督者注入现场文件路径时开启 VM 的 last-pc 定频写(未注入时零开销)。 + if let Some(watch) = pc_watch_path() { + vm.set_pc_watch(Some(watch.clone())); + } let run_result = vm.run(&Arc::new(module)); let dur = start.elapsed().as_millis() as u64; diff --git a/crates/oxide_test262/src/stats.rs b/crates/oxide_test262/src/stats.rs index 4869856c..8632dc3e 100644 --- a/crates/oxide_test262/src/stats.rs +++ b/crates/oxide_test262/src/stats.rs @@ -26,8 +26,10 @@ pub(crate) struct RunStats { pub(crate) spawn_errors: usize, pub(crate) wait_errors: usize, pub(crate) hb_write_errors: usize, - /// 超时/崩溃清单:(index, elapsed_ms);elapsed_ms=0 表示非超时崩溃。 - pub(crate) timeout_crashes: Vec<(usize, u64)>, + /// 超时/崩溃清单:(index, elapsed_ms, scene);elapsed_ms=0 表示非超时崩溃, + /// scene 为挂死点的 last-pc 现场行(pc/opcode/flat_id/frames),缺文件/ + /// 解析失败为 None。 + pub(crate) timeout_crashes: Vec<(usize, u64, Option)>, } impl RunStats { @@ -159,27 +161,30 @@ mod tests { assert_eq!(a.fail_record_bytes, a.fail_records.iter().map(|r| r.message.len()).sum::()); } - /// merge 合并异常计数器:spawn/wait/hb 求和、timeout_crashes 拼接。 + /// merge 合并异常计数器:spawn/wait/hb 求和、timeout_crashes 拼接(含 scene)。 #[test] fn runstats_merge_sums_error_counters() { let mut a = RunStats { spawn_errors: 1, wait_errors: 2, hb_write_errors: 3, - timeout_crashes: vec![(0, 100)], + timeout_crashes: vec![(0, 100, None)], ..RunStats::default() }; let b = RunStats { spawn_errors: 4, wait_errors: 5, hb_write_errors: 6, - timeout_crashes: vec![(1, 200)], + timeout_crashes: vec![(1, 200, Some("pc=9 op=ADD flat_id=0 frames=1".to_string()))], ..RunStats::default() }; a.merge(b); assert_eq!(a.spawn_errors, 5); assert_eq!(a.wait_errors, 7); assert_eq!(a.hb_write_errors, 9); - assert_eq!(a.timeout_crashes, vec![(0, 100), (1, 200)]); + assert_eq!( + a.timeout_crashes, + vec![(0, 100, None), (1, 200, Some("pc=9 op=ADD flat_id=0 frames=1".to_string()))] + ); } } diff --git a/crates/oxide_test262/src/supervise.rs b/crates/oxide_test262/src/supervise.rs index 0ac8f18b..2e8771bd 100644 --- a/crates/oxide_test262/src/supervise.rs +++ b/crates/oxide_test262/src/supervise.rs @@ -35,20 +35,33 @@ fn merge_fail_log(stats: &mut RunStats, hb_path: &Path, consumed: &mut usize) { } } +/// 读子进程被杀前留下的 last-pc 现场行:取文件末行,须含 pc/op/frames 三字段 +/// 才接受(缺文件 / 空文件 / 残行均返回 None)。 +fn read_pc_scene(path: &Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + let last = content.lines().last()?.trim(); + if last.starts_with("pc=") && last.contains(" op=") && last.contains(" frames=") { + Some(last.to_string()) + } else { + None + } +} + /// 记一笔超时/崩溃结果:默认计入 skip,`--no-skip` 下计入 fail(归入 /// `timeout/crash` 类别,父进程无法进一步拆分根因)。 /// /// # 副作用 -/// - 把 `(index, elapsed_ms)` 追加进 timeout_crashes 清单(elapsed_ms=0 表示 -/// 非超时崩溃:spawn 失败 / 中途退出 / waiterr / 无心跳),供收尾逐路径报告。 -fn record_timeout_or_crash(stats: &mut RunStats, no_skip: bool, index: usize, elapsed_ms: u64) { +/// - 把 `(index, elapsed_ms, scene)` 追加进 timeout_crashes 清单(elapsed_ms=0 +/// 表示非超时崩溃:spawn 失败 / 中途退出 / waiterr / 无心跳;scene 为挂死点 +/// 的 last-pc 现场行,缺文件/解析失败为 None),供收尾逐路径报告。 +fn record_timeout_or_crash(stats: &mut RunStats, no_skip: bool, index: usize, elapsed_ms: u64, scene: Option<&str>) { if no_skip { stats.fail += 1; *stats.fail_categories.entry("timeout/crash".into()).or_insert(0) += 1; } else { stats.skip += 1; } - stats.timeout_crashes.push((index, elapsed_ms)); + stats.timeout_crashes.push((index, elapsed_ms, scene.map(str::to_string))); } /// 在监督下运行一个窗口 `[wstart, wend)`,返回经过多次子进程重启 @@ -69,6 +82,8 @@ fn supervise_window( // 旁路失败行已消费字节偏移:跨重启只并入新增段(见 merge_fail_log)。 let mut fails_consumed: usize = 0; let hb_path = std::env::temp_dir().join(format!("oxide_t262_hb_{}_{}.txt", std::process::id(), window_id)); + // last-pc 现场文件:子进程运行期定频追加写,监督者杀子进程后读回末行。 + let pc_path = std::env::temp_dir().join(format!("oxide_t262_pc_{}_{}.txt", std::process::id(), window_id)); let describe = |idx: usize| -> String { paths @@ -85,6 +100,7 @@ fn supervise_window( // 新行是真实新事件而非重复并入。 let _ = std::fs::remove_file(&hb_path); let _ = std::fs::remove_file(format!("{}.tmp", hb_path.display())); + let _ = std::fs::remove_file(&pc_path); let max_tests = wend - cur; let mut child = match Command::new(exe) @@ -93,6 +109,7 @@ fn supervise_window( .env("OXIDE_MAX_TESTS", max_tests.to_string()) .env("OXIDE_TEST262_WORKERS", "1") .env("OXIDE_TEST262_HEARTBEAT", &hb_path) + .env("OXIDE_TEST262_PC_WATCH", &pc_path) .env("OXIDE_TEST262_CHILD_CHUNK", "1") .env("OXIDE_TEST262_ALLOW_FAIL_EXIT", "1") .env_remove("OXIDE_TEST262_CHUNK_SIZE") @@ -104,7 +121,7 @@ fn supervise_window( Err(err) => { eprintln!(" window {window_id}: failed to spawn child at index {cur}: {err}"); stats.spawn_errors += 1; - record_timeout_or_crash(&mut stats, no_skip, cur, 0); + record_timeout_or_crash(&mut stats, no_skip, cur, 0, None); cur += 1; continue; } @@ -127,12 +144,13 @@ fn supervise_window( merge_heartbeat(&mut stats, &hb); let culprit = hb.index + 1; merge_fail_log(&mut stats, &hb_path, &mut fails_consumed); + let scene = read_pc_scene(&pc_path); eprintln!( " [warn] window {window_id}: child exited ({status}) mid-test #{}: {}", culprit, describe(culprit) ); - record_timeout_or_crash(&mut stats, no_skip, culprit, 0); + record_timeout_or_crash(&mut stats, no_skip, culprit, 0, scene.as_deref()); cur = culprit; } None => { @@ -140,7 +158,8 @@ fn supervise_window( " [warn] window {window_id}: child exited ({status}) with no heartbeat at index {cur}; skipping one" ); merge_fail_log(&mut stats, &hb_path, &mut fails_consumed); - record_timeout_or_crash(&mut stats, no_skip, cur, 0); + let scene = read_pc_scene(&pc_path); + record_timeout_or_crash(&mut stats, no_skip, cur, 0, scene.as_deref()); cur += 1; } } @@ -153,7 +172,8 @@ fn supervise_window( let _ = child.kill(); let _ = child.wait(); merge_fail_log(&mut stats, &hb_path, &mut fails_consumed); - record_timeout_or_crash(&mut stats, no_skip, cur, 0); + let scene = read_pc_scene(&pc_path); + record_timeout_or_crash(&mut stats, no_skip, cur, 0, scene.as_deref()); cur += 1; break; } @@ -173,7 +193,7 @@ fn supervise_window( }; if elapsed > deadline { - // 先杀再读:心跳与旁路失败行都须在子进程死亡后取最终状态。 + // 先杀再读:心跳、旁路失败行与 last-pc 现场都须在子进程死亡后取最终状态。 let _ = child.kill(); let _ = child.wait(); let hb = read_heartbeat(&hb_path); @@ -182,12 +202,14 @@ fn supervise_window( merge_heartbeat(&mut stats, h); } merge_fail_log(&mut stats, &hb_path, &mut fails_consumed); + let scene = read_pc_scene(&pc_path); eprintln!( - " [timeout] window {window_id}: TIMEOUT ({}s) on test #{culprit}: {}", + " [timeout] window {window_id}: TIMEOUT ({}s) on test #{culprit}: {}{}", deadline.as_secs(), - describe(culprit) + describe(culprit), + scene.as_deref().map(|s| format!(" [{s}]")).unwrap_or_default() ); - record_timeout_or_crash(&mut stats, no_skip, culprit, elapsed.as_millis() as u64); + record_timeout_or_crash(&mut stats, no_skip, culprit, elapsed.as_millis() as u64, scene.as_deref()); cur = culprit + 1; break; } @@ -198,6 +220,7 @@ fn supervise_window( let _ = std::fs::remove_file(&hb_path); let _ = std::fs::remove_file(format!("{}.tmp", hb_path.display())); + let _ = std::fs::remove_file(&pc_path); // 旁路失败行归档而非删除:统计已由心跳合并入父进程,此文件是收尾逐文件 // 差分的终态依据(删除即永久丢记录)。 let _ = std::fs::rename(hb_path.with_extension("fails"), hb_path.with_extension("fails.done")); @@ -342,12 +365,15 @@ pub(crate) fn run_supervised( } if !stats.timeout_crashes.is_empty() { println!(" --- TIMEOUT/CRASH list ({}) ---", stats.timeout_crashes.len()); - for (idx, elapsed_ms) in &stats.timeout_crashes { + for (idx, elapsed_ms, scene) in &stats.timeout_crashes { let path = paths .get(*idx) .map(|p| p.display().to_string()) .unwrap_or_else(|| format!("#{idx}")); - println!(" {idx} {elapsed_ms}ms {path}"); + match scene { + Some(s) => println!(" {idx} {elapsed_ms}ms {path} [{s}]"), + None => println!(" {idx} {elapsed_ms}ms {path}"), + } } } println!("═══════════════════════════════════════"); @@ -360,17 +386,38 @@ mod tests { use super::*; use crate::report::append_fail_log; - /// 超时/崩溃记账:计数(no_skip 转 fail)与 timeout_crashes 入列。 + /// 超时/崩溃记账:计数(no_skip 转 fail)与 timeout_crashes 入列(scene 原样 + /// 存串,缺现场为 None)。 #[test] fn record_timeout_or_crash_records_index_elapsed() { let mut stats = RunStats::default(); - record_timeout_or_crash(&mut stats, true, 3, 2500); + record_timeout_or_crash(&mut stats, true, 3, 2500, Some("pc=1 op=ADD flat_id=0 frames=1")); assert_eq!(stats.fail, 1); assert_eq!(stats.fail_categories.get("timeout/crash"), Some(&1)); - assert_eq!(stats.timeout_crashes, vec![(3, 2500)]); - record_timeout_or_crash(&mut stats, false, 4, 0); + assert_eq!(stats.timeout_crashes, vec![(3, 2500, Some("pc=1 op=ADD flat_id=0 frames=1".to_string()))]); + record_timeout_or_crash(&mut stats, false, 4, 0, None); assert_eq!(stats.skip, 1); - assert_eq!(stats.timeout_crashes, vec![(3, 2500), (4, 0)]); + assert_eq!( + stats.timeout_crashes, + vec![(3, 2500, Some("pc=1 op=ADD flat_id=0 frames=1".to_string())), (4, 0, None)] + ); + } + + /// read_pc_scene 防御解析:缺文件 / 空文件 / 残行(缺 frames 字段)均 None, + /// 末行完整时接受(忽略中间行)。 + #[test] + fn read_pc_scene_defensive_parse() { + let dir = std::env::temp_dir(); + let path = dir.join(format!("oxide_t262_pc_test_{}.txt", std::process::id())); + assert_eq!(read_pc_scene(&path), None, "缺文件返回 None"); + std::fs::write(&path, "").expect("写失败"); + assert_eq!(read_pc_scene(&path), None, "空文件返回 None"); + std::fs::write(&path, "pc=1 op=ADD flat_id=0 frames=1\npc=2 op=ADD flat_id=0 frames=1\n").expect("写失败"); + assert_eq!(read_pc_scene(&path), Some("pc=2 op=ADD flat_id=0 frames=1".to_string())); + // 残行:末行缺 frames 字段,返回 None。 + std::fs::write(&path, "pc=1 op=ADD flat_id=0 frames=1\npc=2 op=AD").expect("写失败"); + assert_eq!(read_pc_scene(&path), None, "残行返回 None"); + let _ = std::fs::remove_file(&path); } /// merge_fail_log 重建 fail_records:类别经 id 表重映射、消息 cap; diff --git a/crates/oxide_vm/src/vm/dispatch_loop.rs b/crates/oxide_vm/src/vm/dispatch_loop.rs index add49f4c..19f25aa1 100644 --- a/crates/oxide_vm/src/vm/dispatch_loop.rs +++ b/crates/oxide_vm/src/vm/dispatch_loop.rs @@ -1,6 +1,8 @@ //! dispatch 主循环:逐指令解码与 OpCode 臂分派、执行期 GC 安全点、 //! 步数/分配上限采样与二元运算宏 `binary_arith!`。 +use std::io::Write; + use num_traits::Zero; use oxide_bytecode::opcode::{self, OpCode}; use oxide_runtime_api as coercion; @@ -216,6 +218,21 @@ impl Vm { eprintln!("pc={pc:4} {op:>16} rd={rd} a={a} b={b}"); } + // last-pc 现场:开启时每 2^16 指令追加写一行 pc/opcode/flat_id/frames, + // 供监督者超时/崩溃杀子进程后读回挂死点(精度 = 距挂死点 2^16 指令内); + // 关闭时零输出零分配。 + if let Some(watch) = self.pc_watch.as_ref() { + if (steps & 0xFFFF) == 0 { + let line = + format!("pc={pc} op={op} flat_id={} frames={}\n", self.active_flat_id, self.frames.len()); + let _ = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(watch) + .and_then(|mut f| f.write_all(line.as_bytes())); + } + } + match op { OpCode::NOP => {} diff --git a/crates/oxide_vm/src/vm/mod.rs b/crates/oxide_vm/src/vm/mod.rs index c1d7cf66..6abd5ebb 100644 --- a/crates/oxide_vm/src/vm/mod.rs +++ b/crates/oxide_vm/src/vm/mod.rs @@ -4,6 +4,7 @@ #![allow(clippy::arc_with_non_send_sync)] use std::collections::{HashMap, VecDeque}; +use std::path::PathBuf; use std::sync::Arc; #[cfg(test)] use std::sync::OnceLock; @@ -354,6 +355,10 @@ pub struct Vm { /// `pc + opcode + 操作数`。运行时开关,旁路 tracing 级别上限(release /// 构建可用);默认关闭,关闭时热路径仅一次 bool 比较,零输出零分配。 pub trace_instructions: bool, + /// last-pc 现场文件路径:开启时 dispatch 主循环每 2^16 指令追加写一行 + /// `pc/opcode/flat_id/frames`,供监督者超时/崩溃杀子进程后读回挂死点; + /// 默认 None(零输出零分配)。 + pub(crate) pc_watch: Option, } impl Drop for Vm { @@ -423,6 +428,13 @@ impl Vm { self.trace_instructions = on; } + /// 设置 last-pc 现场文件路径(开启后 dispatch 主循环每 2^16 指令追加写一行 + /// `pc/opcode/flat_id/frames`,供监督者超时/崩溃杀子进程后读回挂死点); + /// None 清除。 + pub fn set_pc_watch(&mut self, path: Option) { + self.pc_watch = path; + } + /// 只读访问 VM 的 epoch arena。 pub fn epoch(&self) -> &Epoch { &self.epoch diff --git a/crates/oxide_vm/src/vm_support.rs b/crates/oxide_vm/src/vm_support.rs index c035b0fe..a37426ce 100644 --- a/crates/oxide_vm/src/vm_support.rs +++ b/crates/oxide_vm/src/vm_support.rs @@ -158,6 +158,7 @@ impl Vm { saved_flat_id_stack: Vec::new(), saved_table_gen_stack: Vec::new(), trace_instructions: false, + pc_watch: None, }; vm.init_generator_intrinsics(); vm.init_promise_intrinsics(); @@ -293,6 +294,7 @@ impl Vm { saved_flat_id_stack: Vec::new(), saved_table_gen_stack: Vec::new(), trace_instructions: false, + pc_watch: None, }; vm.init_generator_intrinsics(); vm.init_promise_intrinsics();