pmacs/tests/m6_perf_acceptance.rs

825 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! M6 perf gates (T M6.6, T M6.7).
//!
//! # Contract
//!
//! Per `pmacs-tasks.tex` T M6.6 / T M6.7 and `pmacs-spec.tex`
//! §sec:repl-perf, M6.6 imposes three perf gates against a
//! fast-spewing producer ingested through the REPL package:
//!
//! 1. **Sustained ingest rate ≥ 100 MB/s** over a 10 s window after
//! warmup. Computed as bytes appended to the rope per second.
//! 2. **Buffer memory ≤ 200 MB RSS delta** for the duration of the
//! run, sampled at 100 Hz; a single excursion above is a fail.
//! 3. **Cancel response latency p99 ≤ 100 ms** over 100 trials,
//! each cancelling a fresh producer at a random point in the
//! first 5 seconds.
//!
//! M6.7 adds two more gates against a populated 10000-line scrollback
//! buffer:
//!
//! 4. **Navigation latency p99 ≤ 16 ms** for cursor motion across the
//! scrollback, 1000 trials cycling through `move_down`, `move_up`,
//! `move_page_down`, `move_page_up`.
//! 5. **Search latency p99 ≤ 100 ms** for buffer scans across the
//! scrollback, 1000 trials with needles distributed across early,
//! mid, and late position bands.
//!
//! # Methodology
//!
//! Locked here so future "is this regression real or measurement
//! noise?" debates have a single source of truth.
//!
//! - **What the producer is.** The spec names `find /` as the
//! workload exemplar. The gate's metric is *pmacs's rope-append
//! rate*, not the producer's read rate. `find /` is impractical
//! in CI: non-deterministic across runners, requires elevated
//! permissions for some paths, and easily produces over 200 MB on
//! developer machines (which would trip the memory ceiling for
//! reasons unrelated to pmacs's behavior). We use synthetic
//! producers (`yes "<line>"` for the infinite cases) that exhibit
//! the same high-rate spew profile and are reproducible across
//! environments.
//!
//! - **Tick cadence during measurement.** Tight loop; no artificial
//! 60 Hz cap. The gate measures *upper-bound capacity*: what
//! pmacs is capable of when given the CPU. A future "frame-rate-
//! capped throughput" gate (M6.7+) could measure user-perceived
//! throughput at 60 Hz.
//!
//! - **What "bytes appended to the rope" means.** Every byte that
//! reaches `Handle:append_output` and lands in history (i.e.,
//! passes the ANSI parser as a `Text` event and gets inserted via
//! `_emit_history`). Bytes consumed by escape sequences (SGR,
//! alt-screen markers, OSC titles) do not count toward ingest
//! because they don't grow the rope; this matches the spec's
//! "appended to the rope" wording exactly.
//!
//! - **Warmup vs measurement window.** 1 s warmup absorbs first-tick
//! costs (`LuaJIT` trace compilation, supervisor reader-thread
//! startup, kernel pipe buffer fills). The 10 s measurement
//! window is then the spec-specified gate.
//! CI may override `PMACS_M6_INGEST_MIN_BYTES_PER_SEC` for the
//! hosted-runner profile; omitting it keeps the full 100 MB/s gate.
//!
//! - **RSS sampling source.** Linux-only via `/proc/self/status`'s
//! `VmRSS:` field (kilobytes; multiply by 1024 for bytes). We
//! prefer `status` over `statm` because the latter reports pages
//! and the page-size lookup needs `sysconf(_SC_PAGESIZE)`, which
//! requires an `unsafe` block — incompatible with the project's
//! `forbid(unsafe_code)` posture. macOS would need a different
//! API; the M5 perf-gates workflow already pins CI to
//! ubuntu-latest, and we follow that precedent.
//!
//! - **Sampler thread cadence.** 100 Hz (10 ms inter-sample sleep).
//! `std::thread::sleep` is best-effort; expect ~95100 samples per
//! second under typical CI load. The gate is "no excursion above
//! 200 MB", which holds regardless of sampling jitter. The CI
//! fixture retains ~100 MiB of synthetic output so allocator and
//! hosted-runner variance have room below that hard ceiling.
//!
//! - **Cancel-latency definition.** From `pmacs.process.signal(id,
//! "INT")` returning to the moment `_on_exit` has run, observed
//! via the handle's `_exited` boolean. That flag is set as the
//! first thing in `_on_exit`, immediately before the exit marker
//! is written; sampling the flag is O(1) regardless of history
//! size, whereas grepping the rope for the marker text is O(N)
//! and would conflate cancel-response time with rope-scan cost
//! on trials with multi-hundred-MB history (the worst case under
//! `yes` at the M6.6 ingest rate). Both signals fire in the same
//! tick, so the choice is purely a measurement-cost decision.
//!
//! - **Why we cancel `yes` directly, not a shell.** PTY-mode
//! `pmacs.process.signal(_proc_id, "INT")` targets the foreground
//! process group. For a shell, that group can include the shell's
//! current foreground job rather than only the shell process. For
//! the M6.6 gate, the cleanest measurement is a single-process
//! target (`yes`), so the foreground group contains the producer
//! being measured and no shell/job-control policy enters the timing.
//!
//! - **Percentile computation.** Sort the latency samples; p99 is
//! `samples[(len * 99) / 100]`, matching M5.9c's exact-integer
//! formula. For len=100 this is index 99 (the 100th smallest of
//! 100 — i.e., the max). Conservative: a single bad trial fails
//! the gate at len=100. M6.7 gates use len=1000 to stabilize p99
//! (index 990 = 11th-from-max), since 100-sample p99 has too much
//! variance for a 16 ms threshold to be a regression signal rather
//! than measurement noise.
//!
//! - **Random-delay generator.** Hand-rolled xorshift seeded by
//! trial index. No `rand` dev-dep; reproducible across runs;
//! varied enough that we don't always hit the same supervisor
//! tick boundary. Methodology: random in `[10 ms, 5000 ms]` per
//! spec ("first 5 seconds"). CI may override the trial count and
//! delay ceiling with `PMACS_M6_CANCEL_TRIALS` and
//! `PMACS_M6_CANCEL_MAX_DELAY_MS` so the hosted perf job fits
//! under the runner's effective wall-clock ceiling; omitting those
//! env vars runs the full spec profile.
//!
//! - **M6.7 buffer-direct populate.** The 10000-line scrollback is
//! built via the public buffer API (`pmacs.buffer.create` +
//! `Buffer:insert`), not through a real REPL with a producer
//! process. The gate measures cursor-motion and search latency,
//! which are buffer-level operations; injecting REPL machinery
//! (PTY scheduling, supervisor ticks, parser overhead) measures
//! the wrong thing. The user-felt experience is "cursor moved
//! within one frame," and the buffer-direct path is what dispatch
//! ultimately runs.
//!
//! - **M6.7 navigation cycle.** Each trial calls one of
//! `move_down`, `move_up`, `move_page_down`, `move_page_up` (round-
//! robin by trial index). All four are O(log N) over the rope, so
//! the gate is dominated by view-update / cursor-position math,
//! not by traversal. We cycle rather than fix on one operation so
//! p99 reflects the realistic mixed workload an interactive user
//! produces.
//!
//! - **M6.7 search position bands.** Needles are placed at trial-
//! varying line numbers spanning early (lines 1..3333), mid
//! (3334..6666), and late (6667..10000) bands. Without this
//! variation the gate would measure only the early-termination
//! case (fast) or only the full-scan case (slow), neither of
//! which reflects realistic isearch usage. Each needle is unique
//! (`PMACS_M67_PAD_<n>`) so trial N's search doesn't false-hit
//! on trial N-1's marker.
//!
//! # Why `#[ignore]`
//!
//! Perf measurement under debug-mode `cargo test` is meaningless.
//! CI runs these tests release-mode via the `m6-perf-gates` workflow
//! job (`cargo test --release --test m6_perf_acceptance -- --ignored
//! --nocapture`). Local dev runs (`cargo test`) skip them.
#![cfg(target_os = "linux")]
use pmacs::editor::EditorState;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
// ---------------------------------------------------------------------------
// Shared harness
// ---------------------------------------------------------------------------
/// Spawn the given argv as a REPL via the M6.5 surface. Returns the
/// proc-id raw integer for caller-side bookkeeping; the handle and
/// after-tick wiring live entirely Lua-side.
fn spawn_repl(editor: &mut EditorState, argv: &[&str]) -> i64 {
use std::fmt::Write as _;
let mut argv_lua = String::new();
for (i, a) in argv.iter().enumerate() {
if i > 0 {
argv_lua.push_str(", ");
}
write!(&mut argv_lua, "\"{a}\"").unwrap();
}
let chunk = format!(
r"
_G.h = pmacs.repl.spawn {{ argv = {{ {argv_lua} }} }}
return _G.h._proc_id:raw()
",
);
editor
.lua_host
.lua()
.load(&chunk)
.eval()
.expect("spawn_repl chunk runs and returns raw id")
}
/// Read history-region byte length via the handle's `:history_end()`
/// query. Matches the spec wording exactly: "bytes appended to the
/// rope per second."
fn history_bytes(editor: &mut EditorState) -> i64 {
editor
.lua_host
.lua()
.load("return _G.h:history_end()")
.eval()
.expect("history_end query")
}
/// Pump the supervisor + after-tick hook until `predicate` returns
/// true or the deadline elapses. Returns true on predicate-met,
/// false on timeout.
fn pump_until(
editor: &mut EditorState,
predicate: impl Fn(&mut EditorState) -> bool,
timeout: Duration,
) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
editor.tick_processes();
if predicate(editor) {
return true;
}
std::thread::sleep(Duration::from_millis(2));
}
false
}
/// Wait for the spawned process to reach the `running` state. Without
/// this, the test's first ticks measure pre-exec state and skew the
/// warmup. 5 s budget is generous; the supervisor typically reaches
/// running in under 100 ms.
fn wait_until_running(editor: &mut EditorState) {
let ok = pump_until(
editor,
|e| {
let kind: String = e
.lua_host
.lua()
.load(
r#"
local s = pmacs.process.status(_G.h._proc_id)
return (s and s.kind) or "nil"
"#,
)
.eval()
.expect("status query");
kind == "running"
},
Duration::from_secs(5),
);
assert!(ok, "spawned producer never reached running state");
}
fn env_usize(name: &str, default: usize) -> usize {
std::env::var(name)
.ok()
.and_then(|raw| raw.parse::<usize>().ok())
.filter(|value| *value > 0)
.unwrap_or(default)
}
fn env_u64(name: &str, default: u64) -> u64 {
std::env::var(name)
.ok()
.and_then(|raw| raw.parse::<u64>().ok())
.unwrap_or(default)
}
// ---------------------------------------------------------------------------
// T M6.6 acceptance bullet 1: sustained ingest rate
// ---------------------------------------------------------------------------
/// 100 MB/s sustained ingest gate. Producer is `yes` repeating a
/// 100-character line, which produces ~200 MB/s on modern hardware
/// — fast enough that pmacs is the bottleneck, which is what the
/// gate measures.
#[test]
#[ignore = "perf gate; requires release build"]
fn m6_6_sustained_ingest_rate_meets_100mbps_gate() {
const WARMUP: Duration = Duration::from_secs(1);
const WINDOW: Duration = Duration::from_secs(10);
const DEFAULT_RATE_THRESHOLD_BYTES_PER_SEC: u64 = 100 * 1024 * 1024;
let rate_threshold_bytes_per_sec = env_u64(
"PMACS_M6_INGEST_MIN_BYTES_PER_SEC",
DEFAULT_RATE_THRESHOLD_BYTES_PER_SEC,
)
.max(1);
let mut editor = EditorState::new_with_roots(&crate::iso::roots());
// 100 chars + newline per line. The exact value is unimportant;
// what matters is that `yes` blasts at a higher rate than pmacs's
// ingest path, so pmacs is the bottleneck under measurement.
let line = "a".repeat(100);
let _proc_raw = spawn_repl(&mut editor, &["yes", &line]);
wait_until_running(&mut editor);
// Warmup: drain ticks for 1s without measuring.
let warmup_deadline = Instant::now() + WARMUP;
while Instant::now() < warmup_deadline {
editor.tick_processes();
}
let bytes_at_window_start = history_bytes(&mut editor);
let window_start = Instant::now();
let window_deadline = window_start + WINDOW;
while Instant::now() < window_deadline {
editor.tick_processes();
}
let elapsed = window_start.elapsed();
let bytes_at_window_end = history_bytes(&mut editor);
// Tear down the producer so it doesn't keep spewing into the
// supervisor's bound channel during teardown / next test.
let _ = editor
.lua_host
.lua()
.load(
r#"
pcall(pmacs.process.signal, _G.h._proc_id, "INT")
_G.h:close()
"#,
)
.exec();
let ingested = (bytes_at_window_end - bytes_at_window_start) as u64;
let rate = (ingested as f64 / elapsed.as_secs_f64()) as u64;
println!("M6.6 sustained ingest rate gate:");
println!(" window: {elapsed:?}");
println!(
" bytes ingested: {} ({:.1} MiB)",
ingested,
ingested as f64 / (1024.0 * 1024.0)
);
println!(
" rate: {} B/s ({:.1} MiB/s)",
rate,
rate as f64 / (1024.0 * 1024.0)
);
println!(
" threshold: {} B/s ({:.1} MiB/s)",
rate_threshold_bytes_per_sec,
rate_threshold_bytes_per_sec as f64 / (1024.0 * 1024.0)
);
assert!(
rate >= rate_threshold_bytes_per_sec,
"ingest rate {rate} B/s below {rate_threshold_bytes_per_sec} B/s gate"
);
}
// ---------------------------------------------------------------------------
// T M6.6 acceptance bullet 2: buffer memory ceiling
// ---------------------------------------------------------------------------
/// Read `VmRSS` in bytes from `/proc/self/status`. The kernel reports
/// `VmRSS:` in kilobytes; we multiply by 1024. Using `/proc/self/status`
/// (rather than `/proc/self/statm`'s page-count form) avoids a
/// `sysconf(_SC_PAGESIZE)` call, which would require an `unsafe`
/// block — incompatible with the project's `forbid(unsafe_code)`
/// posture.
fn read_rss_bytes() -> u64 {
let s = std::fs::read_to_string("/proc/self/status").expect("read /proc/self/status");
for line in s.lines() {
if let Some(rest) = line.strip_prefix("VmRSS:") {
// "VmRSS: 12345 kB" → split_whitespace handles leading
// whitespace; first token is the kB value.
let kb: u64 = rest
.split_whitespace()
.next()
.expect("VmRSS value")
.parse()
.expect("parse VmRSS kB");
return kb * 1024;
}
}
panic!("VmRSS not found in /proc/self/status");
}
/// 200 MB RSS-delta ceiling for a bounded ~100 MiB run. We bound by
/// byte count rather than by producer-exit so the producer can be
/// `yes` (no shell-quoting concerns). When history reaches the
/// target byte count the test sends SIGINT to terminate the producer
/// cleanly. The sampler thread polls at 100 Hz; the test fails on
/// any single excursion above the ceiling.
#[test]
#[ignore = "perf gate; requires release build"]
fn m6_6_buffer_memory_stays_under_200mb_during_run() {
const TARGET_HISTORY_BYTES: i64 = 100 * 1024 * 1024;
const RSS_CEILING_BYTES: u64 = 200 * 1024 * 1024;
const SAMPLE_INTERVAL: Duration = Duration::from_millis(10);
let baseline_rss = read_rss_bytes();
println!(
"M6.6 memory ceiling gate: baseline RSS {} B ({:.1} MiB)",
baseline_rss,
baseline_rss as f64 / (1024.0 * 1024.0)
);
let mut editor = EditorState::new_with_roots(&crate::iso::roots());
// 100-char line; the line content is unimportant for the ceiling
// gate. Total target = 150 MB of bytes-into-history.
let line = "a".repeat(100);
let _proc_raw = spawn_repl(&mut editor, &["yes", &line]);
wait_until_running(&mut editor);
// Sampler thread. Records the max RSS observed during the run.
let stop_flag = Arc::new(AtomicBool::new(false));
let max_rss = Arc::new(AtomicU64::new(baseline_rss));
{
let stop_flag = stop_flag.clone();
let max_rss = max_rss.clone();
std::thread::spawn(move || {
while !stop_flag.load(Ordering::Relaxed) {
let rss = read_rss_bytes();
let prev = max_rss.load(Ordering::Relaxed);
if rss > prev {
max_rss.store(rss, Ordering::Relaxed);
}
std::thread::sleep(SAMPLE_INTERVAL);
}
});
}
// Pump until history reaches the target size. With the gate of
// 100 MB/s, 150 MB takes ~1.5 s on a fast machine and well under
// 60 s budget on any sane runner.
let reached_target = pump_until(
&mut editor,
|e| history_bytes(e) >= TARGET_HISTORY_BYTES,
Duration::from_mins(1),
);
assert!(
reached_target,
"history did not reach {TARGET_HISTORY_BYTES} bytes within 60 s budget"
);
// Cancel the producer and drain the exit so the sampler still
// catches any post-cancel allocator churn before we stop sampling.
let _ = editor
.lua_host
.lua()
.load(r#"pmacs.process.signal(_G.h._proc_id, "INT")"#)
.exec();
let _ = pump_until(
&mut editor,
|e| {
let exited: bool = e
.lua_host
.lua()
.load("return _G.h._exited == true")
.eval()
.expect("exited query");
exited
},
Duration::from_secs(2),
);
stop_flag.store(true, Ordering::Relaxed);
let final_rss = max_rss.load(Ordering::Relaxed);
let delta = final_rss.saturating_sub(baseline_rss);
let history_len = history_bytes(&mut editor);
println!("M6.6 memory ceiling gate:");
println!(
" history bytes: {history_len} ({:.1} MiB)",
history_len as f64 / (1024.0 * 1024.0)
);
println!(
" max RSS: {} B ({:.1} MiB)",
final_rss,
final_rss as f64 / (1024.0 * 1024.0)
);
println!(
" RSS delta: {} B ({:.1} MiB)",
delta,
delta as f64 / (1024.0 * 1024.0)
);
println!(
" ceiling: {} B ({:.1} MiB)",
RSS_CEILING_BYTES,
RSS_CEILING_BYTES as f64 / (1024.0 * 1024.0)
);
let _ = editor.lua_host.lua().load("_G.h:close()").exec();
assert!(
delta < RSS_CEILING_BYTES,
"RSS delta {delta} exceeded {RSS_CEILING_BYTES} ceiling"
);
}
// ---------------------------------------------------------------------------
// T M6.6 acceptance bullet 3: cancel response latency
// ---------------------------------------------------------------------------
/// Hand-rolled xorshift64 — no `rand` dev-dep needed. Seeded by the
/// trial index for reproducibility. Sufficient variance to spread
/// trials across supervisor tick boundaries.
fn xorshift64(state: &mut u64) -> u64 {
let mut x = *state;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
*state = x;
x
}
/// 100-trial cancel-latency gate. Each trial: spawn `yes`, wait a
/// random delay in [10 ms, 5000 ms] per spec, send SIGINT, measure
/// the time until the exit marker appears in history. p99 of those
/// 100 latencies must be ≤ 100 ms.
#[test]
#[ignore = "perf gate; requires release build"]
fn m6_6_cancel_response_p99_under_100ms() {
const DEFAULT_TRIALS: usize = 100;
const P99_THRESHOLD: Duration = Duration::from_millis(100);
const MIN_DELAY_MS: u64 = 10;
const DEFAULT_MAX_DELAY_MS: u64 = 5000;
const PER_CANCEL_TIMEOUT: Duration = Duration::from_secs(2);
let trials = env_usize("PMACS_M6_CANCEL_TRIALS", DEFAULT_TRIALS);
let max_delay_ms =
env_u64("PMACS_M6_CANCEL_MAX_DELAY_MS", DEFAULT_MAX_DELAY_MS).max(MIN_DELAY_MS);
let mut latencies: Vec<Duration> = Vec::with_capacity(trials);
let mut prng_state: u64 = 0xa5a5_5a5a_dead_beef;
for trial in 0..trials {
// Vary the seed per trial so consecutive trials don't sample
// the same delay; xor with trial index keeps it deterministic.
prng_state ^= trial as u64;
let r = xorshift64(&mut prng_state);
let delay_ms = MIN_DELAY_MS + (r % (max_delay_ms - MIN_DELAY_MS + 1));
let delay = Duration::from_millis(delay_ms);
let mut editor = EditorState::new_with_roots(&crate::iso::roots());
let _ = spawn_repl(&mut editor, &["yes"]);
wait_until_running(&mut editor);
// Pump the supervisor for the random delay to let `yes` spew
// into history. Captures the realistic case of cancelling
// mid-flow rather than the trivial just-spawned case.
let delay_deadline = Instant::now() + delay;
while Instant::now() < delay_deadline {
editor.tick_processes();
std::thread::sleep(Duration::from_millis(2));
}
// Send SIGINT and start the latency clock.
let t0 = Instant::now();
let _ = editor
.lua_host
.lua()
.load(r#"pmacs.process.signal(_G.h._proc_id, "INT")"#)
.exec();
// Pump until `_on_exit` has run (the cheap signal). `_exited`
// is set as the first thing in `_on_exit`, immediately before
// the marker is written. Querying a boolean flag is O(1)
// regardless of history size; querying the marker via
// `buf:slice(0, history_end):find(...)` is O(N) and dominates
// the latency for trials with multi-MB history (the worst case
// — 5s of `yes` at >100 MB/s — produces 500+ MB of history,
// and string scan over that swamps the cancel-response measurement
// we are actually trying to gate).
let saw_exit = pump_until(
&mut editor,
|e| {
e.lua_host
.lua()
.load("return _G.h._exited == true")
.eval::<bool>()
.expect("exited query")
},
PER_CANCEL_TIMEOUT,
);
let elapsed = t0.elapsed();
assert!(
saw_exit,
"trial {trial}: cancel did not transition to exited within {PER_CANCEL_TIMEOUT:?}"
);
latencies.push(elapsed);
let _ = editor.lua_host.lua().load("_G.h:close()").exec();
if (trial + 1) % 10 == 0 || trial + 1 == trials {
println!(" cancel trials completed: {}/{}", trial + 1, trials);
}
}
let mut sorted = latencies.clone();
sorted.sort();
let percentile = |p: usize| -> Duration {
let idx = ((sorted.len() * p) / 100).min(sorted.len() - 1);
sorted[idx]
};
let p50 = percentile(50);
let p90 = percentile(90);
let p99 = percentile(99);
let max = sorted[sorted.len() - 1];
println!("M6.6 cancel-response latency gate ({trials} trials, max delay {max_delay_ms}ms):");
println!(" p50: {p50:?}");
println!(" p90: {p90:?}");
println!(" p99: {p99:?}");
println!(" max: {max:?}");
println!(" threshold: {P99_THRESHOLD:?}");
assert!(
p99 <= P99_THRESHOLD,
"cancel p99 {p99:?} exceeds {P99_THRESHOLD:?} gate; \
p50={p50:?}, p90={p90:?}, max={max:?}"
);
}
// ---------------------------------------------------------------------------
// T M6.7 acceptance bullet 1: navigation latency
// ---------------------------------------------------------------------------
/// Populate `_G.scroll_buf` with `lines` lines of synthetic content.
/// Each line is "line-N: <padding>\n" with N spelled out so search
/// trials can target a known string at a known line. The buffer is
/// then made the active buffer so motion / search dispatch lands on
/// it. The trailing `move_to_start` rewinds the cursor to position 0
/// for predictable starting state.
fn populate_scrollback(editor: &mut EditorState, lines: usize) {
let chunk = format!(
r#"
local buf = pmacs.buffer.create("*scrollback*")
-- Build the content in Lua-side first to avoid {lines}
-- separate insert-edit notifications. One big insert is
-- {lines}x faster and the gate is about post-populate
-- cursor behavior, not populate cost.
local parts = {{}}
for i = 1, {lines} do
parts[i] = string.format(
"line-%d: PMACS_M67_PAD_%d_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n",
i, i)
end
local body = table.concat(parts)
buf:insert(0, body)
pmacs.window.switch_buffer(buf)
_G.scroll_buf = buf
_G.scroll_buf_len = buf:len()
"#,
);
editor
.lua_host
.eval(Some("@populate"), &chunk)
.expect("populate");
}
/// Move the cursor halfway through the buffer. Without this, every
/// trial starts at position 0 and `move_up` / `move_page_up` no-op,
/// skewing the latency distribution low. With the cursor at midpoint,
/// the four motion operations all have somewhere to go.
fn seek_cursor_to_middle(editor: &mut EditorState, lines: usize) {
let down_count = lines / 2;
let chunk = format!(
r"
for _ = 1, {down_count} do pmacs.editor.move_down() end
",
);
editor.lua_host.eval(Some("@seek"), &chunk).expect("seek");
}
/// 1000-trial navigation-latency gate. Each trial dispatches one
/// motion command via `pmacs.command.invoke` (the keymap-equivalent
/// path) and times it. The four commands rotate by trial index so
/// p99 reflects mixed motion rather than one extreme. p99 ≤ 16 ms
/// is the spec gate (single-frame budget at 60 Hz).
#[test]
#[ignore = "perf gate; requires release build"]
fn m6_7_scrollback_navigation_p99_under_16ms() {
const LINES: usize = 10_000;
const TRIALS: usize = 1000;
const P99_THRESHOLD: Duration = Duration::from_millis(16);
let mut editor = EditorState::new_with_roots(&crate::iso::roots());
populate_scrollback(&mut editor, LINES);
seek_cursor_to_middle(&mut editor, LINES);
// Warmup: 100 dispatches that don't get measured. Absorbs LuaJIT
// trace compilation and any first-call costs in command dispatch.
{
let chunk = r#"
for i = 1, 100 do
local op = ({"cursor.down", "cursor.up",
"cursor.page-down", "cursor.page-up"})[(i % 4) + 1]
pmacs.command.invoke(op)
end
"#;
editor
.lua_host
.eval(Some("@warmup"), chunk)
.expect("warmup");
}
let mut latencies: Vec<Duration> = Vec::with_capacity(TRIALS);
let ops = [
"cursor.down",
"cursor.up",
"cursor.page-down",
"cursor.page-up",
];
for trial in 0..TRIALS {
let op = ops[trial % 4];
let chunk = format!(r#"pmacs.command.invoke("{op}")"#);
let t0 = Instant::now();
editor
.lua_host
.eval(Some("@nav-trial"), &chunk)
.expect("nav");
latencies.push(t0.elapsed());
}
let mut sorted = latencies.clone();
sorted.sort();
let percentile = |p: usize| -> Duration {
let idx = ((sorted.len() * p) / 100).min(sorted.len() - 1);
sorted[idx]
};
let p50 = percentile(50);
let p90 = percentile(90);
let p99 = percentile(99);
let max = sorted[sorted.len() - 1];
println!("M6.7 navigation latency gate ({TRIALS} trials, {LINES} lines):");
println!(" p50: {p50:?}");
println!(" p90: {p90:?}");
println!(" p99: {p99:?}");
println!(" max: {max:?}");
println!(" threshold: {P99_THRESHOLD:?}");
assert!(
p99 <= P99_THRESHOLD,
"navigation p99 {p99:?} exceeds {P99_THRESHOLD:?} gate; \
p50={p50:?}, p90={p90:?}, max={max:?}"
);
}
// ---------------------------------------------------------------------------
// T M6.7 acceptance bullet 2: search latency
// ---------------------------------------------------------------------------
/// 1000-trial search-latency gate. Each trial searches the populated
/// buffer for a unique needle whose target line varies across early/
/// mid/late position bands. Search uses `Buffer:slice` + Lua
/// `string.find`; this is the path isearch will dispatch to when
/// implemented. p99 ≤ 100 ms.
#[test]
#[ignore = "perf gate; requires release build"]
fn m6_7_scrollback_search_p99_under_100ms() {
const LINES: usize = 10_000;
const TRIALS: usize = 1000;
const P99_THRESHOLD: Duration = Duration::from_millis(100);
let mut editor = EditorState::new_with_roots(&crate::iso::roots());
populate_scrollback(&mut editor, LINES);
// Warmup: 100 searches at varied positions. Same trace-compilation
// / hot-path-warmup rationale as the navigation gate.
{
let chunk = r#"
for i = 1, 100 do
local target = ((i * 31) % 10000) + 1
local needle = "PMACS_M67_PAD_" .. target .. "_"
local _ = _G.scroll_buf:slice(0, _G.scroll_buf_len):find(needle, 1, true)
end
"#;
editor
.lua_host
.eval(Some("@warmup"), chunk)
.expect("warmup");
}
let mut latencies: Vec<Duration> = Vec::with_capacity(TRIALS);
for trial in 0..TRIALS {
// Spread targets across early/mid/late bands. (trial * 31) mod
// LINES gives a stride pattern that hits every line band
// without clustering; 31 is coprime to 10000.
let target = ((trial * 31) % LINES) + 1;
let chunk = format!(
r#"
local needle = "PMACS_M67_PAD_{target}_"
return _G.scroll_buf:slice(0, _G.scroll_buf_len):find(needle, 1, true)
"#,
);
let t0 = Instant::now();
let pos: Option<i64> = editor.lua_host.lua().load(&chunk).eval().expect("search");
latencies.push(t0.elapsed());
// Sanity: every needle is present in the buffer; if find
// returns nil we've broken the populate / lookup contract.
assert!(
pos.is_some(),
"trial {trial}: needle for line {target} not found"
);
}
let mut sorted = latencies.clone();
sorted.sort();
let percentile = |p: usize| -> Duration {
let idx = ((sorted.len() * p) / 100).min(sorted.len() - 1);
sorted[idx]
};
let p50 = percentile(50);
let p90 = percentile(90);
let p99 = percentile(99);
let max = sorted[sorted.len() - 1];
println!("M6.7 search latency gate ({TRIALS} trials, {LINES} lines):");
println!(" p50: {p50:?}");
println!(" p90: {p90:?}");
println!(" p99: {p99:?}");
println!(" max: {max:?}");
println!(" threshold: {P99_THRESHOLD:?}");
assert!(
p99 <= P99_THRESHOLD,
"search p99 {p99:?} exceeds {P99_THRESHOLD:?} gate; \
p50={p50:?}, p90={p90:?}, max={max:?}"
);
}
// Isolated bootstrap storage roots (see the module docs): an
// integration test is compiled without `cfg(test)`, so a raw
// `EditorState::new()` would read the developer's real `init.lua` and
// write into their real data root.
#[path = "common/iso.rs"]
mod iso;