Skip to main content

agentmux_srv/
boot_timing.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Per-step timing for srv's boot path, logged once as a single summary line
5//! when `AGENTMUXSRV-ESTART` is emitted.
6//!
7//! WHY. `docs/specs/SPEC_FAST_STARTUP_UPGRADE_OWNS_MIGRATIONS_AND_UPDATES_2026_09_15.md`
8//! §3 inventories the boot path and finds several passes that run on *every*
9//! boot and have never been individually timed — the registry `session_id`
10//! catch-up backfill, the transcript backfill, the snapshot-source gap
11//! repair, and the agent auto-seed. The splash's `backend` stage reports one
12//! number covering all of them together, so there is currently no way to say
13//! which of them (if any) is worth moving off the critical path. §6 of that
14//! spec asks for measurement before relocation; this is the instrument.
15//!
16//! Deliberately `tracing`, not the `srv_stderr` splash protocol
17//! (`agentmux_common::srv_stderr`) the per-migration rows use: the direction
18//! of travel is *fewer* splash rows, not more
19//! (`docs/reports/REPORT_SPLASH_MIGRATION_ROW_OVERFLOW_AND_SUMMARY_2026_09_15.md`),
20//! and these numbers are for whoever is profiling a boot, not for the user
21//! watching one. Read them with `muxlog srv grep boot_timing`.
22//!
23//! The global recorder is a process-lifetime singleton because the steps it
24//! wraps are scattered through the long boot functions in
25//! `crates/srv/src/bootstrap/stores.rs` and `crates/srv/src/main.rs`;
26//! threading a `&mut BootTimings` through all of them would be a much larger
27//! diff than the measurement is worth. [`BootTimings`] itself is a plain value
28//! type with no global state, which is what the tests below exercise.
29
30use std::sync::{Mutex, OnceLock};
31use std::time::Instant;
32
33/// One boot's worth of step timings.
34pub struct BootTimings {
35    started: Instant,
36    steps: Vec<(&'static str, u64)>,
37}
38
39impl BootTimings {
40    pub fn new() -> Self {
41        Self { started: Instant::now(), steps: Vec::new() }
42    }
43
44    /// Run `f`, recording how long it took under `step`.
45    #[cfg(test)]
46    pub fn time<T>(&mut self, step: &'static str, f: impl FnOnce() -> T) -> T {
47        let t = Instant::now();
48        let out = f();
49        self.steps.push((step, t.elapsed().as_millis() as u64));
50        out
51    }
52
53    #[cfg(test)]
54    pub fn steps(&self) -> &[(&'static str, u64)] {
55        &self.steps
56    }
57
58    /// Sum of every recorded step. Steps may nest (a wrapped pass calling
59    /// another wrapped pass), in which case this double-counts the inner one —
60    /// which is why the summary reports it alongside the wall-clock total
61    /// rather than deriving one from the other.
62    pub fn accounted_ms(&self) -> u64 {
63        self.steps.iter().map(|(_, ms)| ms).sum()
64    }
65
66    /// Wall-clock since [`BootTimings::new`] — i.e. since srv started timing,
67    /// not since process start.
68    pub fn total_ms(&self) -> u64 {
69        self.started.elapsed().as_millis() as u64
70    }
71
72    /// `total=412ms accounted=350ms | open_stores=210ms registry_session_backfill=88ms ...`
73    ///
74    /// Steps are listed slowest-first: the point of reading this line is
75    /// finding what to move off the boot path, and that is always the top of
76    /// the list.
77    pub fn summary(&self) -> String {
78        let mut sorted: Vec<_> = self.steps.clone();
79        sorted.sort_by(|a, b| b.1.cmp(&a.1));
80        let steps = sorted
81            .iter()
82            .map(|(step, ms)| format!("{step}={ms}ms"))
83            .collect::<Vec<_>>()
84            .join(" ");
85        format!(
86            "total={}ms accounted={}ms | {}",
87            self.total_ms(),
88            self.accounted_ms(),
89            steps
90        )
91    }
92}
93
94impl Default for BootTimings {
95    fn default() -> Self {
96        Self::new()
97    }
98}
99
100// ── Process-global recorder ──────────────────────────────────────────────────
101
102fn recorder() -> &'static Mutex<Option<BootTimings>> {
103    static RECORDER: OnceLock<Mutex<Option<BootTimings>>> = OnceLock::new();
104    RECORDER.get_or_init(|| Mutex::new(None))
105}
106
107/// Start timing this boot. Called once from `main`, after logging is up.
108pub fn init() {
109    *recorder().lock().unwrap() = Some(BootTimings::new());
110}
111
112/// Run `f`, recording its duration under `step` if [`init`] has been called.
113///
114/// Never holds the recorder lock across `f`, so a timed step may itself
115/// contain timed steps without deadlocking. Uninitialized (unit tests,
116/// the `migrate` subcommand, anything that never calls [`init`]) it is a
117/// transparent pass-through.
118pub fn time<T>(step: &'static str, f: impl FnOnce() -> T) -> T {
119    let t = Instant::now();
120    let out = f();
121    let ms = t.elapsed().as_millis() as u64;
122    if let Ok(mut guard) = recorder().lock() {
123        if let Some(timings) = guard.as_mut() {
124            timings.steps.push((step, ms));
125        }
126    }
127    out
128}
129
130/// `time`, for an async step. Same no-lock-across-the-work property.
131pub async fn time_async<T>(step: &'static str, fut: impl std::future::Future<Output = T>) -> T {
132    let t = Instant::now();
133    let out = fut.await;
134    let ms = t.elapsed().as_millis() as u64;
135    if let Ok(mut guard) = recorder().lock() {
136        if let Some(timings) = guard.as_mut() {
137            timings.steps.push((step, ms));
138        }
139    }
140    out
141}
142
143/// Log the one summary line. Called once, where the boot path ends.
144pub fn log_summary() {
145    let Ok(guard) = recorder().lock() else { return };
146    let Some(timings) = guard.as_ref() else { return };
147    // The key is repeated in the MESSAGE, not just the target, on purpose:
148    // `muxlog`'s grep matches `fields.message` only (`renderLine` in
149    // `backend/shellintegration/muxlog.mjs`), so the documented
150    // `muxlog srv grep boot_timing` would filter this very line out if the
151    // key lived in the target alone (codex P2 on PR #3267). The target is
152    // kept as well, so `muxlog srv --target boot_timing` works too.
153    tracing::info!(target: "boot_timing", "boot_timing: {}", timings.summary());
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use std::thread::sleep;
160    use std::time::Duration;
161
162    #[test]
163    fn time_returns_the_inner_value() {
164        let mut t = BootTimings::new();
165        assert_eq!(t.time("step", || 42), 42);
166    }
167
168    #[test]
169    fn steps_are_recorded_in_call_order() {
170        let mut t = BootTimings::new();
171        t.time("first", || {});
172        t.time("second", || {});
173        let names: Vec<_> = t.steps().iter().map(|(n, _)| *n).collect();
174        assert_eq!(names, vec!["first", "second"]);
175    }
176
177    #[test]
178    fn a_steps_duration_reflects_real_elapsed_time() {
179        let mut t = BootTimings::new();
180        t.time("slow", || sleep(Duration::from_millis(12)));
181        let (_, ms) = t.steps()[0];
182        assert!(ms >= 10, "expected >=10ms for a 12ms sleep, got {ms}ms");
183    }
184
185    #[test]
186    fn summary_lists_every_step_slowest_first() {
187        let mut t = BootTimings::new();
188        t.steps.push(("fast", 2));
189        t.steps.push(("slowest", 300));
190        t.steps.push(("middle", 40));
191        let s = t.summary();
192        let slowest = s.find("slowest=300ms").expect("slowest step must appear");
193        let middle = s.find("middle=40ms").expect("middle step must appear");
194        let fast = s.find("fast=2ms").expect("fast step must appear");
195        assert!(slowest < middle && middle < fast, "steps must be ordered slowest-first: {s}");
196    }
197
198    #[test]
199    fn summary_reports_total_and_accounted_separately() {
200        // They are not derivable from each other: `accounted` double-counts
201        // nested steps, and `total` includes boot-path time no step wraps.
202        let mut t = BootTimings::new();
203        t.steps.push(("a", 10));
204        t.steps.push(("b", 20));
205        let s = t.summary();
206        assert!(s.contains("accounted=30ms"), "{s}");
207        assert!(s.contains("total="), "{s}");
208    }
209
210    #[test]
211    fn global_time_runs_the_closure_even_when_uninitialized() {
212        // Every unit test in this crate runs without `init()`; a pass-through
213        // that silently skipped the work would be catastrophic.
214        let mut ran = false;
215        time("never-initialized", || ran = true);
216        assert!(ran);
217    }
218
219    #[test]
220    fn nested_global_timing_does_not_deadlock() {
221        // The lock must not be held across the closure —
222        // `open_stores_and_migrate` wraps passes that are themselves wrapped.
223        let out = time("outer", || time("inner", || 7));
224        assert_eq!(out, 7);
225    }
226}