test: 支持并发测试 test262
This commit is contained in:
@@ -6,6 +6,7 @@ use oxide_vm::vm::{init_kernel_builtins, Vm};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
@@ -107,6 +108,34 @@ struct RunStats {
|
|||||||
fail_categories: HashMap<String, usize>,
|
fail_categories: HashMap<String, usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl RunStats {
|
||||||
|
/// Fold another worker's partial stats into this one. Used to reduce
|
||||||
|
/// per-worker results back into a single total after parallel execution.
|
||||||
|
fn merge(&mut self, other: RunStats) {
|
||||||
|
self.pass += other.pass;
|
||||||
|
self.fail += other.fail;
|
||||||
|
self.skip += other.skip;
|
||||||
|
self.total_ms += other.total_ms;
|
||||||
|
for (cat, count) in other.fail_categories {
|
||||||
|
*self.fail_categories.entry(cat).or_insert(0) += count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a single test result into the running totals.
|
||||||
|
fn record(&mut self, result: &TestResult) {
|
||||||
|
match &result.outcome {
|
||||||
|
TestOutcome::Pass(_) => self.pass += 1,
|
||||||
|
TestOutcome::Fail(msg) => {
|
||||||
|
let cat = categorize_fail(msg);
|
||||||
|
*self.fail_categories.entry(cat).or_insert(0) += 1;
|
||||||
|
self.fail += 1;
|
||||||
|
}
|
||||||
|
TestOutcome::Skip(_) => self.skip += 1,
|
||||||
|
}
|
||||||
|
self.total_ms += result.duration_ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct RunConfig {
|
struct RunConfig {
|
||||||
test262_root: Option<PathBuf>,
|
test262_root: Option<PathBuf>,
|
||||||
@@ -491,6 +520,62 @@ fn discover_tests(test262_root: &Path) -> Vec<PathBuf> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-test pipeline shared by the serial and parallel execution paths:
|
||||||
|
/// read the file, parse metadata, apply skip filters, then run. Returns
|
||||||
|
/// exactly one `TestResult`. Worker-owned state (`kernel`, `harness_sources`,
|
||||||
|
/// `harness_cache`) never crosses a thread boundary.
|
||||||
|
fn process_path(
|
||||||
|
path: &Path, filter: &Option<String>, no_skip: bool, kernel: &Arc<OxideKernel>,
|
||||||
|
harness_sources: &HarnessSources, harness_cache: &mut HarnessPrefixCache,
|
||||||
|
) -> TestResult {
|
||||||
|
let source = match std::fs::read_to_string(path) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => return TestResult::skip(path.to_path_buf(), format!("read error: {e}")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let meta = match parse_meta(&source) {
|
||||||
|
Some(m) => m,
|
||||||
|
None => return TestResult::skip(path.to_path_buf(), String::from("no YAML metadata")),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(filter_str) = filter {
|
||||||
|
let path_str = path.to_string_lossy().replace('\\', "/");
|
||||||
|
if !path_str.contains(filter_str.as_str()) {
|
||||||
|
return TestResult::skip(path.to_path_buf(), "filtered".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if path.to_string_lossy().contains("staging") {
|
||||||
|
return TestResult::skip(path.to_path_buf(), "staging tests excluded".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !no_skip {
|
||||||
|
if let Some(reason) = is_skipped(&meta) {
|
||||||
|
return TestResult::skip(path.to_path_buf(), reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run_test(path, &source, &meta, kernel, harness_sources, harness_cache, no_skip)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a runner kernel with a bounded step limit. Each parallel worker owns
|
||||||
|
/// its own kernel because `OxideKernel` is `!Send` (it holds `P<JsObject>` =
|
||||||
|
/// `Arc<JsObject>`, and `JsObject` stores raw `*mut u8` property pointers).
|
||||||
|
/// Nothing kernel-shaped can cross a thread boundary, so sharing is impossible;
|
||||||
|
/// per-worker construction is the only correct design.
|
||||||
|
fn build_runner_kernel() -> Arc<OxideKernel> {
|
||||||
|
// Bound each test's execution so a single infinite-loop / unsupported-feature
|
||||||
|
// loop fails (or skips) instead of stalling the run. The VM emits a
|
||||||
|
// "VM step limit exceeded" error on overrun, which the runner classifies as a
|
||||||
|
// step-limit result (skip by default, fail under --no-skip). Override only the
|
||||||
|
// runner's local config; KernelConfig::minimal() stays unbounded for other crates.
|
||||||
|
let mut kernel_config = KernelConfig::minimal();
|
||||||
|
kernel_config.max_steps = Some(50_000_000);
|
||||||
|
let kernel = Arc::new(OxideKernel::new(kernel_config));
|
||||||
|
init_kernel_builtins(&kernel);
|
||||||
|
kernel
|
||||||
|
}
|
||||||
|
|
||||||
fn categorize_fail(msg: &str) -> String {
|
fn categorize_fail(msg: &str) -> String {
|
||||||
if msg.contains("compile error:") {
|
if msg.contains("compile error:") {
|
||||||
let reason = msg.trim_start_matches("compile error: ").trim();
|
let reason = msg.trim_start_matches("compile error: ").trim();
|
||||||
@@ -599,90 +684,82 @@ fn run_tests() -> bool {
|
|||||||
let paths = discover_tests(&test262_root);
|
let paths = discover_tests(&test262_root);
|
||||||
eprintln!("found {} test files", paths.len());
|
eprintln!("found {} test files", paths.len());
|
||||||
|
|
||||||
// Bound each test's execution so a single infinite-loop / unsupported-feature
|
|
||||||
// loop fails (or skips) instead of stalling the whole serial run. The VM already
|
|
||||||
// emits a "VM step limit exceeded" error on overrun, which the runner classifies
|
|
||||||
// as a step-limit result (skip by default, fail under --no-skip). Override only the
|
|
||||||
// runner's local config; KernelConfig::minimal() stays unbounded for other crates.
|
|
||||||
let mut kernel_config = KernelConfig::minimal();
|
|
||||||
kernel_config.max_steps = Some(50_000_000);
|
|
||||||
let kernel = Arc::new(OxideKernel::new(kernel_config));
|
|
||||||
init_kernel_builtins(&kernel);
|
|
||||||
let harness_sources = HarnessSources::new();
|
|
||||||
let mut harness_cache = HarnessPrefixCache::new();
|
|
||||||
|
|
||||||
let mut stats = RunStats::default();
|
|
||||||
let mut results: Vec<TestResult> = Vec::new();
|
|
||||||
|
|
||||||
let total = paths.len();
|
let total = paths.len();
|
||||||
for (i, path) in paths.iter().enumerate() {
|
|
||||||
if i % 500 == 0 {
|
|
||||||
eprintln!(
|
|
||||||
" progress: {}/{} ({}% pass={} fail={} skip={})",
|
|
||||||
i,
|
|
||||||
total,
|
|
||||||
i * 100 / total,
|
|
||||||
stats.pass,
|
|
||||||
stats.fail,
|
|
||||||
stats.skip
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let source = match std::fs::read_to_string(path) {
|
// Determine worker count. `OxideKernel`/`Vm` are `!Send`, so we cannot share
|
||||||
Ok(s) => s,
|
// one kernel via Arc across threads; instead each worker builds and owns its
|
||||||
Err(e) => {
|
// own kernel + harness-source registry + prefix cache. Only `PathBuf` and
|
||||||
results.push(TestResult::skip(path.clone(), format!("read error: {e}")));
|
// `TestResult` (both `Send`) cross thread boundaries. Workers pull test
|
||||||
stats.skip += 1;
|
// indices from a shared atomic cursor for dynamic load balancing.
|
||||||
continue;
|
let workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1).min(total.max(1));
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let meta = match parse_meta(&source) {
|
eprintln!("running on {workers} worker thread(s)");
|
||||||
Some(m) => m,
|
|
||||||
None => {
|
|
||||||
results.push(TestResult::skip(path.clone(), String::from("no YAML metadata")));
|
|
||||||
stats.skip += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(filter_str) = &filter {
|
let cursor = AtomicUsize::new(0);
|
||||||
let path_str = path.to_string_lossy().replace('\\', "/");
|
let progress = AtomicUsize::new(0);
|
||||||
if !path_str.contains(filter_str.as_str()) {
|
let filter = &filter;
|
||||||
results.push(TestResult::skip(path.clone(), "filtered".into()));
|
let no_skip = config.no_skip;
|
||||||
stats.skip += 1;
|
let paths_ref = &paths;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let path_str = path.to_string_lossy();
|
// Each worker returns its partial stats plus (index, result) pairs so the
|
||||||
if path_str.contains("staging") {
|
// final results vector can be restored to discovery order for stable output.
|
||||||
results.push(TestResult::skip(path.clone(), "staging tests excluded".into()));
|
let partials: Vec<(RunStats, Vec<(usize, TestResult)>)> = std::thread::scope(|scope| {
|
||||||
stats.skip += 1;
|
let handles: Vec<_> = (0..workers)
|
||||||
continue;
|
.map(|_| {
|
||||||
}
|
let cursor = &cursor;
|
||||||
|
let progress = &progress;
|
||||||
|
// Match main()'s 16MB stack: the VM recurses on deeply nested
|
||||||
|
// test programs and would overflow the default worker stack.
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.stack_size(16 * 1024 * 1024)
|
||||||
|
.spawn_scoped(scope, move || {
|
||||||
|
let kernel = build_runner_kernel();
|
||||||
|
let harness_sources = HarnessSources::new();
|
||||||
|
let mut harness_cache = HarnessPrefixCache::new();
|
||||||
|
let mut stats = RunStats::default();
|
||||||
|
let mut out: Vec<(usize, TestResult)> = Vec::new();
|
||||||
|
|
||||||
if !config.no_skip {
|
loop {
|
||||||
if let Some(reason) = is_skipped(&meta) {
|
let i = cursor.fetch_add(1, Ordering::Relaxed);
|
||||||
results.push(TestResult::skip(path.clone(), reason));
|
if i >= total {
|
||||||
stats.skip += 1;
|
break;
|
||||||
continue;
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = run_test(path, &source, &meta, &kernel, &harness_sources, &mut harness_cache, config.no_skip);
|
let result = process_path(
|
||||||
match &result.outcome {
|
&paths_ref[i],
|
||||||
TestOutcome::Pass(_) => stats.pass += 1,
|
filter,
|
||||||
TestOutcome::Fail(msg) => {
|
no_skip,
|
||||||
let cat = categorize_fail(msg);
|
&kernel,
|
||||||
*stats.fail_categories.entry(cat).or_insert(0) += 1;
|
&harness_sources,
|
||||||
stats.fail += 1;
|
&mut harness_cache,
|
||||||
}
|
);
|
||||||
TestOutcome::Skip(_) => stats.skip += 1,
|
stats.record(&result);
|
||||||
}
|
out.push((i, result));
|
||||||
stats.total_ms += result.duration_ms;
|
|
||||||
results.push(result);
|
let done = progress.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
|
if done % 500 == 0 || done == total {
|
||||||
|
eprintln!(" progress: {done}/{total} ({}%)", done * 100 / total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(stats, out)
|
||||||
|
})
|
||||||
|
.expect("failed to spawn test262 worker thread")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
handles.into_iter().map(|h| h.join().expect("test262 worker thread panicked")).collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reduce partial stats and restore discovery order for deterministic output.
|
||||||
|
let mut stats = RunStats::default();
|
||||||
|
let mut indexed: Vec<(usize, TestResult)> = Vec::with_capacity(total);
|
||||||
|
for (partial_stats, partial_results) in partials {
|
||||||
|
stats.merge(partial_stats);
|
||||||
|
indexed.extend(partial_results);
|
||||||
}
|
}
|
||||||
|
indexed.sort_by_key(|(i, _)| *i);
|
||||||
|
let results: Vec<TestResult> = indexed.into_iter().map(|(_, r)| r).collect();
|
||||||
|
|
||||||
eprintln!();
|
eprintln!();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user