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