Skip to main content

stryke/
perf_recorder.rs

1//! Wall-clock recorder for `stryke --record`. Writes one row per stryke
2//! invocation to `~/.stryke/perf.sqlite`, queryable via the `perfview`
3//! builtin.
4//!
5//! Design:
6//! - Recording is **opt-in** via the `--record` CLI flag or `STRYKE_RECORD=1`
7//!   env var. When set on the parent process the env var inherits to every
8//!   child stryke process, so `s --record t TESTS...` records one row per
9//!   test file plus one row for the parent.
10//! - One SQLite write per invocation, sub-ms on WAL-mode storage. On failure
11//!   (locked db, missing $HOME, permission error) recording silently no-ops
12//!   so script execution is never broken.
13//! - Auto-prune rows older than 90 days every ~1000 inserts so the DB stays
14//!   bounded without manual maintenance.
15//!
16//! Schema:
17//! ```sql
18//! CREATE TABLE runs (
19//!   id INTEGER PRIMARY KEY,
20//!   path TEXT NOT NULL,           -- canonicalized abs path, or "<repl>"/"<eval>"/"<stdin>"/"<subcmd:NAME>"
21//!   argv TEXT,                    -- json-encoded argv
22//!   started_ns INTEGER NOT NULL,  -- unix ns at process start
23//!   duration_ns INTEGER NOT NULL, -- wall-clock ns from start → drop
24//!   exit_code INTEGER NOT NULL,
25//!   version TEXT NOT NULL,        -- CARGO_PKG_VERSION
26//!   host TEXT,
27//!   pid INTEGER,
28//!   parent_pid INTEGER
29//! );
30//! ```
31
32use rusqlite::{params, Connection};
33use std::path::PathBuf;
34use std::sync::atomic::{AtomicI32, AtomicUsize, Ordering};
35use std::sync::OnceLock;
36use std::time::{Instant, SystemTime, UNIX_EPOCH};
37
38/// Returns `~/.stryke/perf.sqlite`, creating the parent dir if needed.
39/// Honors `$STRYKE_HOME` for callers that override the root.
40fn perf_db_path() -> Option<PathBuf> {
41    let base = if let Ok(home) = std::env::var("STRYKE_HOME") {
42        PathBuf::from(home)
43    } else if let Ok(home) = std::env::var("HOME") {
44        PathBuf::from(home).join(".stryke")
45    } else {
46        return None;
47    };
48    if std::fs::create_dir_all(&base).is_err() {
49        return None;
50    }
51    Some(base.join("perf.sqlite"))
52}
53
54/// Open the perf db in WAL mode and ensure the schema. Returns `None` on
55/// any failure so callers can silently skip recording.
56pub fn open_db() -> Option<Connection> {
57    let path = perf_db_path()?;
58    let conn = Connection::open(&path).ok()?;
59    // WAL gives single-writer no-blocking-reader semantics — keeps `perfview`
60    // queries snappy even while a parallel test pool is inserting.
61    let _ = conn.pragma_update(None, "journal_mode", "WAL");
62    let _ = conn.pragma_update(None, "synchronous", "NORMAL");
63    conn.execute_batch(
64        "CREATE TABLE IF NOT EXISTS runs (
65            id INTEGER PRIMARY KEY,
66            path TEXT NOT NULL,
67            argv TEXT,
68            started_ns INTEGER NOT NULL,
69            duration_ns INTEGER NOT NULL,
70            exit_code INTEGER NOT NULL,
71            version TEXT NOT NULL,
72            host TEXT,
73            pid INTEGER,
74            parent_pid INTEGER
75         );
76         CREATE INDEX IF NOT EXISTS idx_runs_path ON runs(path);
77         CREATE INDEX IF NOT EXISTS idx_runs_started ON runs(started_ns);
78         CREATE INDEX IF NOT EXISTS idx_runs_duration ON runs(duration_ns);",
79    )
80    .ok()?;
81    Some(conn)
82}
83
84/// Best-effort hostname; empty string if unavailable.
85fn hostname() -> String {
86    std::env::var("HOSTNAME")
87        .or_else(|_| std::env::var("HOST"))
88        .unwrap_or_default()
89}
90
91/// Best-effort json-array encoding of argv. Avoids pulling serde into the
92/// recorder hot path by hand-escaping the small set of chars that matter.
93fn argv_json(argv: &[String]) -> String {
94    let mut s = String::from("[");
95    for (i, a) in argv.iter().enumerate() {
96        if i > 0 {
97            s.push(',');
98        }
99        s.push('"');
100        for c in a.chars() {
101            match c {
102                '"' => s.push_str("\\\""),
103                '\\' => s.push_str("\\\\"),
104                '\n' => s.push_str("\\n"),
105                '\r' => s.push_str("\\r"),
106                '\t' => s.push_str("\\t"),
107                c if (c as u32) < 0x20 => s.push_str(&format!("\\u{:04x}", c as u32)),
108                c => s.push(c),
109            }
110        }
111        s.push('"');
112    }
113    s.push(']');
114    s
115}
116
117/// One row, ready to insert.
118#[derive(Debug, Clone)]
119pub struct RunRow {
120    pub path: String,
121    pub argv: Vec<String>,
122    pub started_ns: i64,
123    pub duration_ns: i64,
124    pub exit_code: i32,
125    pub version: String,
126    pub host: String,
127    pub pid: i64,
128    pub parent_pid: i64,
129}
130
131/// Insert one row. Best-effort: returns false on any failure (db missing,
132/// locked, schema mismatch).
133pub fn insert(row: &RunRow) -> bool {
134    let Some(conn) = open_db() else { return false };
135    let argv_str = argv_json(&row.argv);
136    conn.execute(
137        "INSERT INTO runs (path, argv, started_ns, duration_ns, exit_code, version, host, pid, parent_pid)
138         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
139        params![
140            row.path,
141            argv_str,
142            row.started_ns,
143            row.duration_ns,
144            row.exit_code,
145            row.version,
146            row.host,
147            row.pid,
148            row.parent_pid,
149        ],
150    )
151    .is_ok()
152}
153
154/// Filter spec for `query()`. All fields are optional.
155#[derive(Debug, Default, Clone)]
156pub struct QueryFilter {
157    /// Substring or regex applied to `path`. Empty = no filter.
158    pub name_substr: Option<String>,
159    /// Regex applied to `path`. Falls back to substring on regex compile failure.
160    pub name_regex: Option<String>,
161    /// Inclusive lower bound on `started_ns`.
162    pub since_ns: Option<i64>,
163    /// Exact path match (canonicalized).
164    pub exact_path: Option<String>,
165    /// `Some(true)` = duration desc (slowest first), `Some(false)` = asc.
166    /// `None` = id desc (most recent first).
167    pub slowest_first: Option<bool>,
168    /// Max rows to return.
169    pub limit: usize,
170}
171
172impl QueryFilter {
173    pub fn slowest_top(n: usize) -> Self {
174        Self {
175            slowest_first: Some(true),
176            limit: n,
177            ..Default::default()
178        }
179    }
180}
181
182/// One queried row.
183#[derive(Debug, Clone)]
184pub struct QueryRow {
185    pub id: i64,
186    pub path: String,
187    pub argv: String,
188    pub started_ns: i64,
189    pub duration_ns: i64,
190    pub exit_code: i32,
191    pub version: String,
192    pub host: String,
193    pub pid: i64,
194    pub parent_pid: i64,
195}
196
197/// Run a query. Best-effort: returns empty Vec on any failure.
198pub fn query(f: &QueryFilter) -> Vec<QueryRow> {
199    let Some(conn) = open_db() else {
200        return Vec::new();
201    };
202    let mut sql = String::from(
203        "SELECT id, path, argv, started_ns, duration_ns, exit_code, version,
204                COALESCE(host, ''), COALESCE(pid, 0), COALESCE(parent_pid, 0)
205         FROM runs",
206    );
207    let mut clauses: Vec<String> = Vec::new();
208    let mut bind: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
209    if let Some(p) = &f.exact_path {
210        clauses.push("path = ?".to_string());
211        bind.push(Box::new(p.clone()));
212    }
213    if let Some(s) = &f.name_substr {
214        clauses.push("path LIKE ?".to_string());
215        bind.push(Box::new(format!("%{}%", s)));
216    }
217    if let Some(ns) = f.since_ns {
218        clauses.push("started_ns >= ?".to_string());
219        bind.push(Box::new(ns));
220    }
221    if !clauses.is_empty() {
222        sql.push_str(" WHERE ");
223        sql.push_str(&clauses.join(" AND "));
224    }
225    match f.slowest_first {
226        Some(true) => sql.push_str(" ORDER BY duration_ns DESC"),
227        Some(false) => sql.push_str(" ORDER BY duration_ns ASC"),
228        None => sql.push_str(" ORDER BY id DESC"),
229    }
230    let limit = if f.limit == 0 { 1000 } else { f.limit };
231    sql.push_str(&format!(" LIMIT {}", limit));
232
233    let mut stmt = match conn.prepare(&sql) {
234        Ok(s) => s,
235        Err(_) => return Vec::new(),
236    };
237    let params_refs: Vec<&dyn rusqlite::ToSql> = bind.iter().map(|b| b.as_ref()).collect();
238    let mut out: Vec<QueryRow> = Vec::new();
239    let rows = stmt.query_map(rusqlite::params_from_iter(params_refs), |r| {
240        Ok(QueryRow {
241            id: r.get(0)?,
242            path: r.get(1)?,
243            argv: r.get::<_, Option<String>>(2)?.unwrap_or_default(),
244            started_ns: r.get(3)?,
245            duration_ns: r.get(4)?,
246            exit_code: r.get(5)?,
247            version: r.get(6)?,
248            host: r.get(7)?,
249            pid: r.get(8)?,
250            parent_pid: r.get(9)?,
251        })
252    });
253    if let Ok(iter) = rows {
254        for r in iter.flatten() {
255            // Optional regex filter applied client-side.
256            if let Some(rx) = &f.name_regex {
257                if let Ok(re) = regex::Regex::new(rx) {
258                    if !re.is_match(&r.path) {
259                        continue;
260                    }
261                }
262            }
263            out.push(r);
264        }
265    }
266    out
267}
268
269/// Parse a duration string like `7d`, `24h`, `30m`, `90s` → seconds.
270/// Returns `None` on parse failure.
271pub fn parse_duration_secs(s: &str) -> Option<i64> {
272    let s = s.trim();
273    if s.is_empty() {
274        return None;
275    }
276    let (num_str, unit) = match s.chars().last() {
277        Some(c) if c.is_ascii_alphabetic() => (&s[..s.len() - 1], c.to_ascii_lowercase()),
278        _ => (s, 's'),
279    };
280    let n: i64 = num_str.parse().ok()?;
281    let mult = match unit {
282        's' => 1,
283        'm' => 60,
284        'h' => 3600,
285        'd' => 86_400,
286        'w' => 86_400 * 7,
287        _ => return None,
288    };
289    Some(n * mult)
290}
291
292/// Prune rows older than `days` days. Best-effort.
293pub fn prune_older_than(days: i64) -> Option<usize> {
294    let conn = open_db()?;
295    let cutoff_ns = (now_ns() - days * 86_400 * 1_000_000_000).max(0);
296    conn.execute("DELETE FROM runs WHERE started_ns < ?1", params![cutoff_ns])
297        .ok()
298}
299
300/// Counter for occasional auto-prune. Doesn't need atomicity — the only
301/// cost of a duplicate prune is a redundant `DELETE WHERE started_ns < ...`
302/// which is a no-op the second time.
303fn maybe_auto_prune() {
304    static COUNTER: AtomicUsize = AtomicUsize::new(0);
305    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
306    if n.is_multiple_of(1000) && n > 0 {
307        let _ = prune_older_than(90);
308    }
309}
310
311/// Wall-clock ns at unix epoch.
312pub fn now_ns() -> i64 {
313    SystemTime::now()
314        .duration_since(UNIX_EPOCH)
315        .map(|d| d.as_nanos() as i64)
316        .unwrap_or(0)
317}
318
319/// Global recorder state. Set once by `install`; read by `atexit_record`.
320struct RecorderState {
321    started_at: Instant,
322    started_ns: i64,
323    path: String,
324    argv: Vec<String>,
325}
326
327static RECORDER: OnceLock<RecorderState> = OnceLock::new();
328static EXIT_CODE: AtomicI32 = AtomicI32::new(0);
329
330/// Install the perf recorder. Captures process start time and registers an
331/// `atexit` handler that writes one SQLite row when the process exits via
332/// any path (`process::exit`, normal main return, libc::exit). Safe to call
333/// at most once per process; subsequent calls no-op.
334///
335/// `path` is the canonical "what was run" (script path, `<repl>`, `<eval>`,
336/// `<subcmd:NAME>`). `argv` is the full invocation argv (including
337/// `argv[0]`).
338///
339/// `<repl>` invocations are not recorded — they include `--test-worker` /
340/// `--remote-worker` pool processes (which have no script-path argv but
341/// fork dozens of children that would otherwise all write `<repl>` rows)
342/// and interactive REPL sessions (not meaningful as wall-clock data points).
343/// Explicit `s --record -e '...'` still records as `<eval>` since `-e`
344/// argv is preserved.
345pub fn install(path: String, argv: Vec<String>) {
346    if path == "<repl>" {
347        return; // skip pool workers / interactive REPL; see doc above
348    }
349    if RECORDER
350        .set(RecorderState {
351            started_at: Instant::now(),
352            started_ns: now_ns(),
353            path,
354            argv,
355        })
356        .is_err()
357    {
358        return; // already installed; ignore second call
359    }
360
361    // Register libc atexit handler. This fires for `process::exit`, normal
362    // main() return, and explicit `libc::exit` — every path stryke uses.
363    // SAFETY: the registered function is a plain `extern "C"` with no
364    // captures; libc::atexit accepts up to 32 handlers by spec, well below
365    // any usage stryke would hit.
366    unsafe {
367        libc::atexit(atexit_record);
368    }
369
370    // Panic hook: record exit code 101 (Rust's default panic exit code)
371    // before unwind reaches atexit. Chains to any existing hook.
372    let prev = std::panic::take_hook();
373    std::panic::set_hook(Box::new(move |info| {
374        EXIT_CODE.store(101, Ordering::Relaxed);
375        prev(info);
376    }));
377}
378
379/// Record an explicit exit code. Call this immediately before
380/// `process::exit(N)` to capture `N`. Without this, atexit records `0`
381/// (the libc atexit API doesn't expose the actual exit code).
382pub fn set_exit_code(code: i32) {
383    EXIT_CODE.store(code, Ordering::Relaxed);
384}
385
386/// libc atexit callback. Reads global recorder state, builds the row,
387/// inserts. Never panics (uses best-effort error handling).
388extern "C" fn atexit_record() {
389    let Some(state) = RECORDER.get() else { return };
390    let duration_ns = state.started_at.elapsed().as_nanos() as i64;
391    let pid = std::process::id() as i64;
392    let parent_pid = parent_pid();
393    let row = RunRow {
394        path: state.path.clone(),
395        argv: state.argv.clone(),
396        started_ns: state.started_ns,
397        duration_ns,
398        exit_code: EXIT_CODE.load(Ordering::Relaxed),
399        version: env!("CARGO_PKG_VERSION").to_string(),
400        host: hostname(),
401        pid,
402        parent_pid,
403    };
404    let _ = insert(&row);
405    maybe_auto_prune();
406}
407
408#[cfg(unix)]
409fn parent_pid() -> i64 {
410    // SAFETY: getppid is always safe; returns pid_t.
411    unsafe { libc::getppid() as i64 }
412}
413
414#[cfg(not(unix))]
415fn parent_pid() -> i64 {
416    0
417}
418
419/// Returns `true` if the parent process requested recording via env.
420/// Inherited automatically by child stryke processes.
421pub fn recording_enabled_in_env() -> bool {
422    std::env::var("STRYKE_RECORD")
423        .map(|v| !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"))
424        .unwrap_or(false)
425}
426
427/// Derive the canonical `path` field for a stryke invocation given argv.
428/// Returns `(path, argv_to_record)`.
429/// - First positional file arg → its canonical absolute path
430/// - `-e` / `--exec` → "&lt;eval&gt;"
431/// - REPL (no positional, stdin tty) → "&lt;repl&gt;"
432/// - Subcommand → "&lt;subcmd:NAME&gt;"
433pub fn classify_invocation(argv: &[String]) -> String {
434    if argv.len() <= 1 {
435        return "<repl>".to_string();
436    }
437    let mut i = 1;
438    while i < argv.len() {
439        let a = &argv[i];
440        if a == "--" {
441            break;
442        }
443        if a == "-e" || a == "--exec" {
444            return "<eval>".to_string();
445        }
446        if a.starts_with('-') {
447            i += 1;
448            continue;
449        }
450        // First non-flag positional. If it's a known subcommand name, return
451        // <subcmd:NAME>; otherwise treat as a script file.
452        if is_subcommand_name(a) {
453            return format!("<subcmd:{}>", a);
454        }
455        if std::path::Path::new(a).exists() {
456            if let Ok(abs) = std::fs::canonicalize(a) {
457                return abs.display().to_string();
458            }
459        }
460        return a.clone();
461    }
462    "<repl>".to_string()
463}
464
465/// Known stryke subcommand names — first positional matching these is
466/// classified as a subcommand rather than a script path.
467fn is_subcommand_name(name: &str) -> bool {
468    matches!(
469        name,
470        "t" | "test"
471            | "check"
472            | "fmt"
473            | "format"
474            | "lint"
475            | "docs"
476            | "doc"
477            | "repl"
478            | "build"
479            | "run"
480            | "install"
481            | "uninstall"
482            | "publish"
483            | "init"
484            | "new"
485            | "search"
486            | "list"
487            | "info"
488            | "lsp"
489            | "completion"
490            | "completions"
491            | "perfview"
492            | "version"
493            | "help"
494    )
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    #[test]
502    fn argv_json_escapes_quotes_and_specials() {
503        let argv = vec!["s".to_string(), "-e".to_string(), "p \"hi\"\n".to_string()];
504        let out = argv_json(&argv);
505        assert_eq!(out, "[\"s\",\"-e\",\"p \\\"hi\\\"\\n\"]");
506    }
507
508    #[test]
509    fn classify_eval() {
510        let argv = vec!["s".to_string(), "-e".to_string(), "p 42".to_string()];
511        assert_eq!(classify_invocation(&argv), "<eval>");
512    }
513
514    #[test]
515    fn classify_repl_when_no_args() {
516        let argv = vec!["s".to_string()];
517        assert_eq!(classify_invocation(&argv), "<repl>");
518    }
519
520    #[test]
521    fn classify_subcommand() {
522        let argv = vec!["s".to_string(), "test".to_string(), "t/".to_string()];
523        assert_eq!(classify_invocation(&argv), "<subcmd:test>");
524    }
525
526    #[test]
527    fn classify_t_short() {
528        let argv = vec!["s".to_string(), "t".to_string(), "t/".to_string()];
529        assert_eq!(classify_invocation(&argv), "<subcmd:t>");
530    }
531
532    #[test]
533    fn recording_enabled_only_when_env_truthy() {
534        let key = "STRYKE_RECORD";
535        let saved = std::env::var(key).ok();
536        std::env::remove_var(key);
537        assert!(!recording_enabled_in_env());
538        std::env::set_var(key, "1");
539        assert!(recording_enabled_in_env());
540        std::env::set_var(key, "0");
541        assert!(!recording_enabled_in_env());
542        std::env::set_var(key, "false");
543        assert!(!recording_enabled_in_env());
544        std::env::set_var(key, "");
545        assert!(!recording_enabled_in_env());
546        match saved {
547            Some(v) => std::env::set_var(key, v),
548            None => std::env::remove_var(key),
549        }
550    }
551}