diff --git a/.isu/issues.json b/.isu/issues.json index 4379c8b..c8edd48 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -3099,7 +3099,7 @@ ], "assigned": [], "author": "piefev", - "state": "open", + "state": "closed", "created_at": "2026-05-22T02:22:48Z" }, { diff --git a/Cargo.lock b/Cargo.lock index 937979f..82ff1bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,6 +65,7 @@ dependencies = [ "we-js", "we-layout", "we-net", + "we-platform", "we-render", "we-style", "we-svg", diff --git a/crates/e2e/Cargo.toml b/crates/e2e/Cargo.toml index 96e916f..eae2351 100644 --- a/crates/e2e/Cargo.toml +++ b/crates/e2e/Cargo.toml @@ -22,6 +22,7 @@ we-image = { path = "../image" } we-js = { path = "../js" } we-layout = { path = "../layout" } we-net = { path = "../net" } +we-platform = { path = "../platform" } we-render = { path = "../render" } we-style = { path = "../style" } we-svg = { path = "../svg" } diff --git a/crates/e2e/src/lib.rs b/crates/e2e/src/lib.rs index 92f7162..3688651 100644 --- a/crates/e2e/src/lib.rs +++ b/crates/e2e/src/lib.rs @@ -13,6 +13,7 @@ pub mod console; pub mod dom_dump; pub mod panic; +pub mod perf; pub mod png_out; pub mod render; pub mod scenario; diff --git a/crates/e2e/src/main.rs b/crates/e2e/src/main.rs index eaaa239..bead193 100644 --- a/crates/e2e/src/main.rs +++ b/crates/e2e/src/main.rs @@ -9,6 +9,8 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; +use we_e2e::perf::PerfBudget; + fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); let opts = match parse_args(&args) { @@ -22,7 +24,7 @@ fn main() -> ExitCode { match opts.mode { Mode::SingleShot { url, out } => single_shot(&url, opts.width, opts.height, &out), - Mode::Scenario { path } => run_scenario(&path, &opts.out_dir), + Mode::Scenario { path } => run_scenario(&path, &opts.out_dir, opts.perf_budget), } } @@ -31,6 +33,7 @@ struct Opts { height: u32, mode: Mode, out_dir: PathBuf, + perf_budget: PerfBudget, } enum Mode { @@ -45,6 +48,8 @@ fn parse_args(args: &[String]) -> Result { let mut out: Option = None; let mut scenario: Option = None; let mut out_dir: Option = None; + let mut perf_wall_ms: Option = None; + let mut perf_rss_mb: Option = None; let mut i = 0; while i < args.len() { @@ -86,6 +91,24 @@ fn parse_args(args: &[String]) -> Result { args.get(i).ok_or("--out-dir: missing value")?, )); } + "--perf-budget-wall-ms" => { + i += 1; + perf_wall_ms = Some( + args.get(i) + .ok_or("--perf-budget-wall-ms: missing value")? + .parse() + .map_err(|_| "--perf-budget-wall-ms: not an integer")?, + ); + } + "--perf-budget-rss-mb" => { + i += 1; + perf_rss_mb = Some( + args.get(i) + .ok_or("--perf-budget-rss-mb: missing value")? + .parse() + .map_err(|_| "--perf-budget-rss-mb: not an integer")?, + ); + } "-h" | "--help" => { print_usage(); std::process::exit(0); @@ -109,6 +132,10 @@ fn parse_args(args: &[String]) -> Result { height, mode, out_dir: out_dir.unwrap_or_else(|| PathBuf::from(".")), + perf_budget: PerfBudget { + wall_ms: perf_wall_ms, + rss_mb: perf_rss_mb, + }, }) } @@ -116,7 +143,8 @@ fn print_usage() { eprintln!( "usage:\n \ we-e2e --url --out [--width W --height H]\n \ - we-e2e --scenario [--out-dir DIR]" + we-e2e --scenario [--out-dir DIR] \ + [--perf-budget-wall-ms N] [--perf-budget-rss-mb N]" ); } @@ -170,7 +198,7 @@ fn panic_artifact_path(out: &Path) -> PathBuf { } } -fn run_scenario(path: &Path, out_dir: &Path) -> ExitCode { +fn run_scenario(path: &Path, out_dir: &Path, budget: PerfBudget) -> ExitCode { let scenario = match we_e2e::scenario::parse(path) { Ok(s) => s, Err(e) => { @@ -184,7 +212,9 @@ fn run_scenario(path: &Path, out_dir: &Path) -> ExitCode { // `/panics/L.txt` artifacts. We still wrap the whole // call as a safety net for panics outside the per-command body (e.g. // during initial setup). - let report = match we_e2e::panic::catch(|| we_e2e::scenario::run(&scenario, out_dir)) { + let report = match we_e2e::panic::catch(|| { + we_e2e::scenario::run_with_budget(&scenario, out_dir, budget) + }) { Ok(r) => r, Err(report) => { let panic_path = out_dir.join("panic.txt"); @@ -210,6 +240,11 @@ fn run_scenario(path: &Path, out_dir: &Path) -> ExitCode { for w in &report.warnings { println!(" WARN {w}"); } + println!( + "PERF: wall={}ms peak_rss={}MB", + report.wall_ms, + report.peak_rss_bytes / (1024 * 1024) + ); if report.passed { println!("RESULT: PASS"); ExitCode::SUCCESS diff --git a/crates/e2e/src/perf.rs b/crates/e2e/src/perf.rs new file mode 100644 index 0000000..c01e19a --- /dev/null +++ b/crates/e2e/src/perf.rs @@ -0,0 +1,265 @@ +//! Per-scenario performance tracking. +//! +//! Tracks wall-clock time for each scenario command, samples resident-set +//! size (RSS) after every command via `we_platform::mem::resident_set_size`, +//! and emits a `perf.json` artifact under the scenario's output directory. +//! +//! The harness reports a budget violation with a `sev-perf` prefix when: +//! - the cumulative wall-clock time exceeds the configured `wall_ms` budget, +//! - or the peak observed RSS exceeds the configured `rss_mb` budget. +//! +//! Budgets are optional (`None` => unlimited). They may be set globally via +//! CLI flags (`--perf-budget-wall-ms`, `--perf-budget-rss-mb`) and overridden +//! per-scenario via the `perf_budget wall_ms ` / `perf_budget rss_mb ` +//! commands. + +use std::path::Path; +use std::time::Instant; + +/// Per-scenario performance budget. `None` means unlimited. +#[derive(Debug, Clone, Copy, Default)] +pub struct PerfBudget { + pub wall_ms: Option, + pub rss_mb: Option, +} + +#[derive(Debug, Clone)] +pub struct CommandTiming { + pub line: usize, + pub command: String, + pub wall_ms: u64, + pub rss_bytes_after: u64, +} + +/// Live perf tracker for a single scenario run. +pub struct PerfTracker { + start: Instant, + budget: PerfBudget, + timings: Vec, + peak_rss_bytes: u64, +} + +impl PerfTracker { + pub fn new(budget: PerfBudget) -> Self { + let mut t = Self { + start: Instant::now(), + budget, + timings: Vec::new(), + peak_rss_bytes: 0, + }; + // Take an initial RSS sample so peak is well-defined even for empty + // scenarios. + t.sample_rss(); + t + } + + pub fn budget(&self) -> PerfBudget { + self.budget + } + + pub fn set_budget(&mut self, budget: PerfBudget) { + self.budget = budget; + } + + fn sample_rss(&mut self) -> u64 { + let rss = we_platform::mem::resident_set_size().unwrap_or(0); + if rss > self.peak_rss_bytes { + self.peak_rss_bytes = rss; + } + rss + } + + /// Record a command's wall-clock duration and sample RSS afterwards. + pub fn record(&mut self, line: usize, command: &str, started_at: Instant) { + let wall_ms = started_at.elapsed().as_millis().min(u64::MAX as u128) as u64; + let rss_after = self.sample_rss(); + self.timings.push(CommandTiming { + line, + command: command.to_string(), + wall_ms, + rss_bytes_after: rss_after, + }); + } + + pub fn cumulative_wall_ms(&self) -> u64 { + self.start.elapsed().as_millis().min(u64::MAX as u128) as u64 + } + + pub fn peak_rss_bytes(&self) -> u64 { + self.peak_rss_bytes + } + + pub fn timings(&self) -> &[CommandTiming] { + &self.timings + } + + /// Check the current state against the budget. Returns the list of + /// violation messages, each prefixed with `sev-perf`. Empty if no + /// violations. + pub fn violations(&self) -> Vec { + let mut out = Vec::new(); + let wall = self.cumulative_wall_ms(); + if let Some(limit) = self.budget.wall_ms { + if wall > limit { + out.push(format!( + "sev-perf wall-clock {wall}ms exceeds budget {limit}ms" + )); + } + } + if let Some(limit_mb) = self.budget.rss_mb { + let limit_bytes = limit_mb.saturating_mul(1024 * 1024); + if self.peak_rss_bytes > limit_bytes { + out.push(format!( + "sev-perf peak RSS {}B exceeds budget {limit_mb}MB ({limit_bytes}B)", + self.peak_rss_bytes, + )); + } + } + out + } + + /// Render the tracker state as a JSON document (no trailing newline). + pub fn to_json(&self) -> String { + let mut s = String::with_capacity(256); + s.push('{'); + push_kv_u64(&mut s, "wall_ms", self.cumulative_wall_ms()); + s.push(','); + push_kv_u64(&mut s, "peak_rss_bytes", self.peak_rss_bytes); + s.push(','); + s.push_str("\"budget\":{"); + match self.budget.wall_ms { + Some(v) => push_kv_u64(&mut s, "wall_ms", v), + None => s.push_str("\"wall_ms\":null"), + } + s.push(','); + match self.budget.rss_mb { + Some(v) => push_kv_u64(&mut s, "rss_mb", v), + None => s.push_str("\"rss_mb\":null"), + } + s.push('}'); + s.push(','); + s.push_str("\"command_timings\":["); + for (i, t) in self.timings.iter().enumerate() { + if i > 0 { + s.push(','); + } + s.push('{'); + push_kv_u64(&mut s, "line", t.line as u64); + s.push(','); + s.push_str("\"command\":"); + push_json_string(&mut s, &t.command); + s.push(','); + push_kv_u64(&mut s, "wall_ms", t.wall_ms); + s.push(','); + push_kv_u64(&mut s, "rss_bytes_after", t.rss_bytes_after); + s.push('}'); + } + s.push(']'); + s.push('}'); + s + } + + /// Write `perf.json` under `out_dir`. + pub fn write_json(&self, out_dir: &Path) -> std::io::Result<()> { + let path = out_dir.join("perf.json"); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, self.to_json()) + } +} + +fn push_kv_u64(s: &mut String, key: &str, value: u64) { + s.push('"'); + s.push_str(key); + s.push_str("\":"); + s.push_str(&value.to_string()); +} + +fn push_json_string(s: &mut String, value: &str) { + s.push('"'); + for c in value.chars() { + match c { + '"' => s.push_str("\\\""), + '\\' => s.push_str("\\\\"), + '\n' => s.push_str("\\n"), + '\r' => s.push_str("\\r"), + '\t' => s.push_str("\\t"), + c if (c as u32) < 0x20 => { + use std::fmt::Write; + let _ = write!(s, "\\u{:04x}", c as u32); + } + c => s.push(c), + } + } + s.push('"'); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn budget_unlimited_has_no_violations() { + let t = PerfTracker::new(PerfBudget::default()); + assert!(t.violations().is_empty()); + } + + #[test] + fn wall_budget_violation_is_sev_perf() { + let mut t = PerfTracker::new(PerfBudget { + wall_ms: Some(0), + rss_mb: None, + }); + // Force at least one millisecond to elapse so the budget is breached. + std::thread::sleep(std::time::Duration::from_millis(2)); + let started = Instant::now(); + t.record(1, "noop", started); + let v = t.violations(); + assert!( + v.iter().any(|m| m.starts_with("sev-perf wall-clock")), + "{v:?}" + ); + } + + #[test] + fn rss_budget_violation_is_sev_perf() { + let mut t = PerfTracker::new(PerfBudget { + wall_ms: None, + rss_mb: Some(0), + }); + let started = Instant::now(); + t.record(1, "noop", started); + let v = t.violations(); + assert!( + v.iter().any(|m| m.starts_with("sev-perf peak RSS")), + "{v:?}" + ); + } + + #[test] + fn to_json_contains_expected_keys() { + let mut t = PerfTracker::new(PerfBudget { + wall_ms: Some(123), + rss_mb: None, + }); + t.record(1, "goto foo", Instant::now()); + let j = t.to_json(); + assert!(j.contains("\"wall_ms\"")); + assert!(j.contains("\"peak_rss_bytes\"")); + assert!(j.contains("\"budget\":{")); + assert!(j.contains("\"command_timings\":[")); + assert!(j.contains("\"command\":\"goto foo\"")); + assert!(j.contains("\"rss_mb\":null")); + } + + #[test] + fn json_escapes_strings() { + let mut t = PerfTracker::new(PerfBudget::default()); + t.record(1, "weird \"quote\" \\ \nbreak", Instant::now()); + let j = t.to_json(); + assert!(j.contains("\\\"quote\\\"")); + assert!(j.contains("\\\\")); + assert!(j.contains("\\n")); + } +} diff --git a/crates/e2e/src/scenario.rs b/crates/e2e/src/scenario.rs index f0f5a08..45646a1 100644 --- a/crates/e2e/src/scenario.rs +++ b/crates/e2e/src/scenario.rs @@ -18,6 +18,8 @@ //! dump_console # write captured console //! assert_dom_contains # fail scenario if DOM dump does not contain //! assert_console_contains # fail scenario if console does not contain +//! perf_budget wall_ms # set scenario wall-clock budget (ms) +//! perf_budget rss_mb # set scenario peak-RSS budget (MB) //! expect_no_panics # marker; the harness already catches every panic. //! panic_now [msg] # debug-only: deliberately panic to exercise the catcher. //! ``` @@ -27,7 +29,9 @@ //! enough to surface a large class of bugs. use std::path::{Path, PathBuf}; +use std::time::Instant; +use crate::perf::{PerfBudget, PerfTracker}; use crate::render::{render, RenderOutput}; #[derive(Debug, Clone)] @@ -60,6 +64,8 @@ pub enum Cmd { AssertConsoleContains(String), ExpectNoPanics, PanicNow(String), + PerfBudgetWallMs(u64), + PerfBudgetRssMb(u64), } pub struct Scenario { @@ -203,6 +209,25 @@ fn parse_line(line: &str) -> Result { "assert_dom_contains" => Ok(Cmd::AssertDomContains(unquote(rest))), "assert_console_contains" => Ok(Cmd::AssertConsoleContains(unquote(rest))), "expect_no_panics" => Ok(Cmd::ExpectNoPanics), + "perf_budget" => { + let mut it = rest.split_whitespace(); + let kind = it + .next() + .ok_or("perf_budget: missing kind (wall_ms|rss_mb)")?; + let value: u64 = it + .next() + .ok_or("perf_budget: missing value")? + .parse() + .map_err(|_| "perf_budget: value not an integer")?; + if it.next().is_some() { + return Err("perf_budget: expected exactly kind and value".into()); + } + match kind { + "wall_ms" => Ok(Cmd::PerfBudgetWallMs(value)), + "rss_mb" => Ok(Cmd::PerfBudgetRssMb(value)), + other => Err(format!("perf_budget: unknown kind {other:?}")), + } + } "panic_now" => { let msg = unquote(rest); let msg = if msg.is_empty() { @@ -232,6 +257,15 @@ pub struct RunReport { pub steps: Vec<(usize, bool, String)>, /// Non-fatal warnings collected while loading the page. pub warnings: Vec, + /// Total wall-clock duration in milliseconds. + pub wall_ms: u64, + /// Peak resident-set size observed during the run, in bytes. + pub peak_rss_bytes: u64, +} + +/// Execute a scenario with the default (unlimited) perf budget. +pub fn run(scenario: &Scenario, out_dir: &Path) -> RunReport { + run_with_budget(scenario, out_dir, PerfBudget::default()) } /// Execute a scenario. Paths in `dump_*`/`screenshot` are resolved relative @@ -241,7 +275,17 @@ pub struct RunReport { /// command writes `/panics/L.txt` and is recorded as a /// failing step, but the runner continues with subsequent commands so the /// cascade is visible in the report. -pub fn run(scenario: &Scenario, out_dir: &Path) -> RunReport { +/// +/// `default_budget` is the initial budget applied to the scenario; in-scenario +/// `perf_budget` commands override it. The harness samples RSS after every +/// command and writes `/perf.json` at the end of the run. If the +/// budget is breached the runner appends a failing step prefixed with +/// `sev-perf`. +pub fn run_with_budget( + scenario: &Scenario, + out_dir: &Path, + default_budget: PerfBudget, +) -> RunReport { let mut viewport = (800u32, 600u32); let mut current: Option = None; let mut steps: Vec<(usize, bool, String)> = Vec::new(); @@ -251,11 +295,15 @@ pub fn run(scenario: &Scenario, out_dir: &Path) -> RunReport { we_browser::loader::set_network_offline(false); we_browser::loader::clear_service_worker_caches(); + let mut perf = PerfTracker::new(default_budget); + for (lineno, cmd) in &scenario.commands { let lineno_v = *lineno; let mut step_log: Vec<(usize, bool, String)> = Vec::new(); let mut new_warnings: Vec = Vec::new(); + let cmd_label = cmd_label(cmd); + let cmd_started = Instant::now(); let outcome = crate::panic::catch(|| { execute_cmd( lineno_v, @@ -264,10 +312,12 @@ pub fn run(scenario: &Scenario, out_dir: &Path) -> RunReport { &mut current, &mut step_log, &mut new_warnings, + &mut perf, scenario_dir, out_dir, ); }); + perf.record(lineno_v, &cmd_label, cmd_started); for entry in &step_log { if !entry.1 { @@ -299,11 +349,45 @@ pub fn run(scenario: &Scenario, out_dir: &Path) -> RunReport { we_browser::loader::set_network_offline(false); + // Check perf budgets and append any violations as failing steps so the + // scenario fails with a clear sev-perf tag. + for v in perf.violations() { + let last_line = scenario.commands.last().map(|(l, _)| *l).unwrap_or(0); + steps.push((last_line, false, v)); + all_ok = false; + } + + // Always write perf.json so the artifact set is consistent. + let _ = perf.write_json(out_dir); + RunReport { scenario: scenario.source.clone(), passed: all_ok, steps, warnings, + wall_ms: perf.cumulative_wall_ms(), + peak_rss_bytes: perf.peak_rss_bytes(), + } +} + +/// Short human-readable label for a command, used in perf timings. +fn cmd_label(cmd: &Cmd) -> String { + match cmd { + Cmd::Goto(url) => format!("goto {url}"), + Cmd::GotoAs { url, .. } => format!("goto_as {url}"), + Cmd::Network(offline) => format!("network {}", if *offline { "offline" } else { "online" }), + Cmd::CachePut { url, .. } => format!("cache_put {url}"), + Cmd::CachePutStatus { url, status, .. } => format!("cache_put_status {url} {status}"), + Cmd::Viewport(w, h) => format!("viewport {w}x{h}"), + Cmd::Screenshot(p) => format!("screenshot {}", p.display()), + Cmd::DumpDom(p) => format!("dump_dom {}", p.display()), + Cmd::DumpConsole(p) => format!("dump_console {}", p.display()), + Cmd::AssertDomContains(s) => format!("assert_dom_contains {s:?}"), + Cmd::AssertConsoleContains(s) => format!("assert_console_contains {s:?}"), + Cmd::ExpectNoPanics => "expect_no_panics".into(), + Cmd::PanicNow(_) => "panic_now".into(), + Cmd::PerfBudgetWallMs(n) => format!("perf_budget wall_ms {n}"), + Cmd::PerfBudgetRssMb(n) => format!("perf_budget rss_mb {n}"), } } @@ -315,6 +399,7 @@ fn execute_cmd( current: &mut Option, steps: &mut Vec<(usize, bool, String)>, warnings: &mut Vec, + perf: &mut PerfTracker, scenario_dir: &Path, out_dir: &Path, ) { @@ -542,5 +627,17 @@ fn execute_cmd( Cmd::ExpectNoPanics => { steps.push((lineno, true, "expect_no_panics".into())); } + Cmd::PerfBudgetWallMs(ms) => { + let mut b = perf.budget(); + b.wall_ms = Some(*ms); + perf.set_budget(b); + steps.push((lineno, true, format!("perf_budget wall_ms {ms}"))); + } + Cmd::PerfBudgetRssMb(mb) => { + let mut b = perf.budget(); + b.rss_mb = Some(*mb); + perf.set_budget(b); + steps.push((lineno, true, format!("perf_budget rss_mb {mb}"))); + } } } diff --git a/crates/e2e/tests/perf_budget.rs b/crates/e2e/tests/perf_budget.rs new file mode 100644 index 0000000..f3b8ec7 --- /dev/null +++ b/crates/e2e/tests/perf_budget.rs @@ -0,0 +1,176 @@ +//! Integration tests for the per-scenario perf budget. +//! +//! Confirms that: +//! - `perf_budget` commands parse and apply to the current scenario. +//! - `perf.json` is produced for every scenario run. +//! - Breaching a tiny wall_ms budget fails the scenario with a `sev-perf` +//! step. +//! - The default (unlimited) budget does not fail scenarios. + +use std::path::PathBuf; + +use we_e2e::perf::PerfBudget; +use we_e2e::scenario::{self, Cmd}; + +fn tmp_dir(name: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "we-e2e-perf-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let _ = std::fs::remove_dir_all(&p); + std::fs::create_dir_all(&p).expect("create tmp dir"); + p +} + +#[test] +fn perf_budget_command_parses() { + let dir = tmp_dir("parse"); + let scenario_path = dir.join("p.we"); + std::fs::write( + &scenario_path, + "perf_budget wall_ms 5000\n\ + perf_budget rss_mb 1024\n", + ) + .expect("write scenario"); + let parsed = scenario::parse(&scenario_path).expect("parse scenario"); + assert!(parsed + .commands + .iter() + .any(|(_, c)| matches!(c, Cmd::PerfBudgetWallMs(5000)))); + assert!(parsed + .commands + .iter() + .any(|(_, c)| matches!(c, Cmd::PerfBudgetRssMb(1024)))); +} + +#[test] +fn perf_json_written_for_every_run() { + let dir = tmp_dir("emit-json"); + let scenario_path = dir.join("p.we"); + std::fs::write(&scenario_path, "viewport 320 200\n").expect("write scenario"); + let parsed = scenario::parse(&scenario_path).expect("parse scenario"); + let report = scenario::run(&parsed, &dir); + assert!( + report.passed, + "default budget should not fail: steps={:?}", + report.steps + ); + let perf_path = dir.join("perf.json"); + assert!( + perf_path.exists(), + "expected {} to exist", + perf_path.display() + ); + let content = std::fs::read_to_string(&perf_path).expect("read perf.json"); + assert!( + content.contains("\"wall_ms\""), + "perf.json missing wall_ms: {content}" + ); + assert!( + content.contains("\"peak_rss_bytes\""), + "perf.json missing peak_rss_bytes: {content}" + ); + assert!( + content.contains("\"command_timings\""), + "perf.json missing command_timings: {content}" + ); + assert!( + content.contains("viewport 320x200"), + "perf.json missing viewport command: {content}" + ); +} + +#[test] +fn tiny_wall_ms_budget_fails_with_sev_perf() { + let dir = tmp_dir("wall-tiny"); + let scenario_path = dir.join("p.we"); + // Set budget to 1ms then perform a sleep using assert_console_contains + // on no page (cheap). The cumulative scenario duration includes the + // PerfTracker setup time which already exceeds 1ms in practice; if not, + // the goto below blows past 1ms easily. + std::fs::write( + &scenario_path, + "perf_budget wall_ms 1\n\ + goto about:blank\n", + ) + .expect("write scenario"); + let parsed = scenario::parse(&scenario_path).expect("parse scenario"); + let report = scenario::run(&parsed, &dir); + assert!(!report.passed, "tight wall_ms budget should fail"); + let sev_perf = report + .steps + .iter() + .find(|(_, ok, msg)| !*ok && msg.contains("sev-perf wall-clock")); + assert!( + sev_perf.is_some(), + "expected a sev-perf wall-clock entry; steps: {:?}", + report.steps + ); +} + +#[test] +fn cli_default_budget_applies_when_no_scenario_command() { + let dir = tmp_dir("cli-default"); + let scenario_path = dir.join("p.we"); + // `goto about:blank` is heavier than `viewport` and will reliably exceed + // a 1ms budget on any machine. + std::fs::write(&scenario_path, "goto about:blank\n").expect("write scenario"); + let parsed = scenario::parse(&scenario_path).expect("parse scenario"); + let report = scenario::run_with_budget( + &parsed, + &dir, + PerfBudget { + wall_ms: Some(1), + rss_mb: None, + }, + ); + assert!(!report.passed); + assert!(report + .steps + .iter() + .any(|(_, ok, msg)| !*ok && msg.contains("sev-perf wall-clock"))); +} + +#[test] +fn scenario_command_overrides_cli_default() { + let dir = tmp_dir("override"); + let scenario_path = dir.join("p.we"); + // CLI default of 1ms is overridden to unlimited by setting a huge budget. + std::fs::write( + &scenario_path, + "perf_budget wall_ms 60000\n\ + viewport 320 200\n", + ) + .expect("write scenario"); + let parsed = scenario::parse(&scenario_path).expect("parse scenario"); + let report = scenario::run_with_budget( + &parsed, + &dir, + PerfBudget { + wall_ms: Some(1), + rss_mb: None, + }, + ); + assert!( + report.passed, + "scenario override should beat CLI default; steps: {:?}", + report.steps + ); +} + +#[test] +fn perf_budget_rejects_bad_kind() { + let dir = tmp_dir("bad-kind"); + let scenario_path = dir.join("p.we"); + std::fs::write(&scenario_path, "perf_budget bytes 5\n").expect("write"); + let err = match scenario::parse(&scenario_path) { + Ok(_) => panic!("should fail parse"), + Err(e) => e, + }; + assert!(err.contains("perf_budget"), "err was: {err}"); +} diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index 5590e05..8614df8 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -3,6 +3,7 @@ pub mod appkit; pub mod cf; pub mod cg; +pub mod mem; pub mod menu; pub mod metal; pub mod objc; diff --git a/crates/platform/src/mem.rs b/crates/platform/src/mem.rs new file mode 100644 index 0000000..afb7491 --- /dev/null +++ b/crates/platform/src/mem.rs @@ -0,0 +1,126 @@ +//! Process memory sampling. +//! +//! Returns the current process's resident-set size in bytes by calling +//! `mach_task_self()` + `task_info(MACH_TASK_BASIC_INFO)`. Numeric struct +//! definitions and constants mirror ``. +//! +//! # Safety +//! +//! Uses raw FFI into `libSystem.dylib` (mach syscalls reach us via +//! `libsystem_kernel.dylib`, which is reachable through `libSystem`). The +//! `platform` crate is one of the few crates where `unsafe` is permitted. + +use std::os::raw::{c_int, c_uint}; + +#[allow(non_camel_case_types)] +type mach_port_t = c_uint; +#[allow(non_camel_case_types)] +type kern_return_t = c_int; +#[allow(non_camel_case_types)] +type task_flavor_t = c_uint; +#[allow(non_camel_case_types)] +type mach_msg_type_number_t = c_uint; +#[allow(non_camel_case_types)] +type integer_t = c_int; +#[allow(non_camel_case_types)] +type natural_t = c_uint; +#[allow(non_camel_case_types)] +type policy_t = c_int; + +// MACH_TASK_BASIC_INFO flavor + struct layout from . +// MACH_TASK_BASIC_INFO is the modern, recommended flavor on 64-bit Darwin — +// it lays out cleanly (resident_size at offset 8) without packing weirdness. +const MACH_TASK_BASIC_INFO: task_flavor_t = 20; +const KERN_SUCCESS: kern_return_t = 0; + +#[repr(C)] +#[allow(non_camel_case_types)] +struct time_value_t { + seconds: integer_t, + microseconds: integer_t, +} + +#[repr(C)] +#[allow(non_camel_case_types)] +struct mach_task_basic_info { + virtual_size: u64, + resident_size: u64, + resident_size_max: u64, + user_time: time_value_t, + system_time: time_value_t, + policy: policy_t, + suspend_count: integer_t, +} + +const MACH_TASK_BASIC_INFO_COUNT: mach_msg_type_number_t = + (std::mem::size_of::() / std::mem::size_of::()) + as mach_msg_type_number_t; + +extern "C" { + fn mach_task_self() -> mach_port_t; + fn task_info( + target_task: mach_port_t, + flavor: task_flavor_t, + task_info_out: *mut integer_t, + task_info_outcnt: *mut mach_msg_type_number_t, + ) -> kern_return_t; +} + +/// Return the current process's resident-set size in bytes, or `None` if +/// the mach call fails for any reason. +pub fn resident_set_size() -> Option { + // SAFETY: `mach_task_basic_info` is repr(C) with the same layout that the + // kernel writes. We pass a pointer to a stack-allocated struct of the + // right size, along with its size in 32-bit words. `mach_task_self()` is + // always valid for the current process. + unsafe { + let mut info = mach_task_basic_info { + virtual_size: 0, + resident_size: 0, + resident_size_max: 0, + user_time: time_value_t { + seconds: 0, + microseconds: 0, + }, + system_time: time_value_t { + seconds: 0, + microseconds: 0, + }, + policy: 0, + suspend_count: 0, + }; + let mut count: mach_msg_type_number_t = MACH_TASK_BASIC_INFO_COUNT; + let kr = task_info( + mach_task_self(), + MACH_TASK_BASIC_INFO, + (&mut info as *mut mach_task_basic_info) as *mut integer_t, + &mut count, + ); + if kr == KERN_SUCCESS { + Some(info.resident_size) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resident_set_size_is_nonzero() { + let rss = resident_set_size().expect("task_info should succeed"); + // The process is running so RSS is always > 0. Use a tiny non-zero + // floor rather than a tight bound to keep the test stable. + assert!(rss > 1024, "rss too small: {rss}"); + } + + #[test] + fn struct_size_matches_apple_header() { + // sizeof(struct mach_task_basic_info) = 48 on aarch64-apple-darwin. + // 48 / sizeof(natural_t=u32) = 12 words. + assert_eq!(std::mem::size_of::(), 48); + assert_eq!(MACH_TASK_BASIC_INFO_COUNT, 12); + } +}