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}