v5.5: boot race instrumentation - claim/firmware/aer latency buckets

Surfaces four boot-race latency metrics as a structured JSON
endpoint at /scheme/driver-manager/timing, plus a per-bucket
p50/p95/p99 summary line appended to /tmp/redbear-boot-timeline.json.

The four metric buckets:
- claim-spawn: time from probe to child daemon ready (wraps the
  config.rs::probe() spawn path).
- firmware-ready: time from NEED_FIRMWARE quirk consultation to
  /scheme/firmware/ registering the needed blob. Also: devices
  with firmware now bind immediately when the scheme is present
  (previously always deferred) - correct behavior, not a workaround.
- governor-switch: time from thermald writing /scheme/cpufreq/governor
  to cpufreqd applying the new governor (v5.1 wired this path;
  bucket is defined but population is in cpufreqd's scope).
- aer-event: pcid->consumer latency parsed from pcid's
  'ts=<rfc3339>' field embedded in each event line.

Architecture:
- timing.rs (NEW): LatencyMetric, Bucket with lock-free AtomicU64
  counters (fetch_min/fetch_max/fetch_update), percentile samples
  in Mutex<Vec<u64>> capped at 4096 per bucket, RFC3339 parser
  that handles epoch seconds, fractional seconds, trailing Z, and
  colon-collision with pcid's key=value field separators.
- config.rs: wraps spawn path with Instant::now()/elapsed timing
  guard; firmware-available scheme check before deferring.
- unified_events.rs: AER consumer records pcid->consumer latency
  by parsing ts= field from the event line.
- scheme.rs: new Timing handle kind, /scheme/driver-manager/timing
  path returns JSON snapshot on every read, root listing updated.
- main.rs: log_timing_snapshot() appends per-bucket p50/p95/p99 to
  boot timeline after enumeration completes (skipped in initfs).

Output format (read via 'cat /scheme/driver-manager/timing'):
  {
    "version": 1,
    "buckets": {
      "claim-spawn": { "count": 17, "min_us": ..., ... },
      "firmware-ready": { ... },
      "governor-switch": { ... },
      "aer-event": { ... }
    }
  }

Tests: 24 new (single record, 100-record percentile ordering,
empty bucket, 8-thread × 500 concurrent records, JSON golden
snapshots, ring buffer capping, percentile index math, RFC3339
parsing variants, civil-to-days algorithm, firmware-defer
lifecycle). Total 112 tests pass, 0 failed.

Compile: cargo check --target x86_64-unknown-redox - zero new
warnings (only pre-existing libredox FFI warnings remain).

Constraints per local/AGENTS.md:
- No new branches (work on 0.3.1)
- No new Cargo dependencies (std-only: AtomicU64, Mutex, Vec, etc.)
- No stubs, no todo!/unimplemented!
- Cat 1 in-house recipe - source IS the durable location

Closes v5.5 of the v5.x work program.
This commit is contained in:
kellito
2026-07-26 00:20:48 +09:00
parent 35e28dd37a
commit 045aaa4579
5 changed files with 968 additions and 21 deletions
@@ -610,6 +610,8 @@ impl Driver for DriverConfig {
// concurrent probe of the same device loses atomically. This is
// the same model pcid-spawner uses — the channel fd doubles as
// the claim and is later handed to the spawned child.
let claim_start = Instant::now();
let device_path = pci_device_path(info);
let mut handle = match PciFunctionHandle::connect_by_path(Path::new(&device_path)) {
Ok(handle) => handle,
@@ -662,17 +664,27 @@ impl Driver for DriverConfig {
);
}
if quirk_decision.needs_firmware {
log::info!(
"quirk-defer: driver={} device={} waiting-for-firmware",
self.name,
device_key
);
return ProbeResult::Deferred {
reason: format!(
"PciQuirkFlags::NEED_FIRMWARE set for {}",
if check_scheme_available("firmware") {
log::info!(
"firmware-ready: driver={} device={} firmware scheme available",
self.name,
device_key
),
};
);
crate::timing::record_firmware_ready_if_deferred(&device_key);
} else {
log::info!(
"quirk-defer: driver={} device={} waiting-for-firmware",
self.name,
device_key
);
crate::timing::record_firmware_defer_start(&device_key);
return ProbeResult::Deferred {
reason: format!(
"PciQuirkFlags::NEED_FIRMWARE set for {}",
device_key
),
};
}
}
if blacklist_match(&self.name) {
@@ -811,12 +823,16 @@ impl Driver for DriverConfig {
match cmd.spawn() {
Ok(child) => {
let pid = child.id();
let claim_spawn_us = claim_start.elapsed().as_micros() as u64;
crate::timing::record("claim-spawn", claim_spawn_us);
log::info!(
"driver {} spawned (pid {}) for device {}",
"driver {} spawned (pid {}) for device {} (claim-spawn {}us)",
self.name,
pid,
device_key
device_key,
claim_spawn_us
);
crate::timing::record_firmware_ready_if_deferred(&device_key);
if let Some((channel, key)) = error_channel {
crate::error_channel::global()
.insert(key, std::sync::Arc::new(channel));
@@ -14,6 +14,7 @@ mod reaper;
mod registry;
mod scheme;
mod sighup;
mod timing;
mod unified_events;
use std::sync::{Arc, Mutex};
@@ -113,6 +114,8 @@ fn run_enumeration(
bound, deferred, enum_duration.as_millis()
);
log_timing_snapshot();
// Success trigger (mirrors Linux's driver_deferred_probe_trigger):
// a successful bind may unblock deferred probes whose dependencies
// were the just-bound drivers. Retry immediately rather than
@@ -202,6 +205,48 @@ fn log_timeline(event: &ProbeEvent) {
}
}
fn log_timing_snapshot() {
if is_initfs_mode() {
return;
}
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let mut entry = format!(r#"{{"ts":{},"event":"timing","buckets":{{"#, timestamp);
let metrics = timing::summary();
let mut first = true;
for m in &metrics {
if !first {
entry.push(',');
}
first = false;
entry.push_str(&format!(
r#""{}":{{"count":{},"min_us":{},"max_us":{},"p50_us":{},"p95_us":{},"p99_us":{}}}"#,
m.bucket, m.count, m.min_us, m.max_us, m.p50_us, m.p95_us, m.p99_us
));
}
entry.push_str("}}");
match OpenOptions::new()
.create(true)
.append(true)
.open(BOOT_TIMELINE_PATH)
{
Ok(mut file) => {
if let Err(err) = writeln!(file, "{entry}") {
log::warn!(
"failed to append timing snapshot to {BOOT_TIMELINE_PATH}: {err}"
);
}
}
Err(err) => {
log::warn!("failed to open boot timeline log at {BOOT_TIMELINE_PATH}: {err}");
}
}
}
fn main() {
log::set_logger(&StderrLogger).ok();
log::set_max_level(log::LevelFilter::Info);
@@ -57,6 +57,7 @@ enum HandleKind {
DriverOverride,
Rescan,
Recover,
Timing,
}
pub struct DriverManagerScheme {
@@ -170,6 +171,7 @@ impl DriverManagerScheme {
["driver_override"] => Ok(HandleKind::DriverOverride),
["rescan"] => Ok(HandleKind::Rescan),
["recover"] => Ok(HandleKind::Recover),
["timing"] => Ok(HandleKind::Timing),
["devices", pci_addr] if Self::valid_pci_addr(pci_addr) => {
let _ = self.device_status(pci_addr)?;
Ok(HandleKind::Device((*pci_addr).to_string()))
@@ -242,7 +244,7 @@ impl DriverManagerScheme {
fn read_handle_string(&self, id: usize, kind: &HandleKind) -> Result<String> {
match kind {
HandleKind::Root => {
Ok("devices\nbound\nevents\nmodalias\nbind\nunbind\nnew_id\nremove_id\ndriver_override\nrescan\nrecover\n".to_string())
Ok("devices\nbound\nevents\nmodalias\nbind\nunbind\nnew_id\nremove_id\ndriver_override\nrescan\nrecover\ntiming\n".to_string())
}
HandleKind::Devices => {
let addresses = self.sorted_bound_addresses().map_err(|err| {
@@ -290,6 +292,7 @@ impl DriverManagerScheme {
HandleKind::RemoveId => Ok("write the same fields as new_id to remove\n".to_string()),
HandleKind::Rescan => Ok("write anything to re-enumerate all buses\n".to_string()),
HandleKind::Recover => Ok("write '<pci_addr> <reset_device|rescan_bus|disconnect>' to trigger AER recovery\n".to_string()),
HandleKind::Timing => Ok(crate::timing::format_json()),
}
}
@@ -309,6 +312,7 @@ impl DriverManagerScheme {
HandleKind::DriverOverride => format!("{SCHEME_NAME}:/driver_override"),
HandleKind::Rescan => format!("{SCHEME_NAME}:/rescan"),
HandleKind::Recover => format!("{SCHEME_NAME}:/recover"),
HandleKind::Timing => format!("{SCHEME_NAME}:/timing"),
}
}
@@ -326,7 +330,8 @@ impl DriverManagerScheme {
| HandleKind::RemoveId
| HandleKind::DriverOverride
| HandleKind::Rescan
| HandleKind::Recover => MODE_FILE | 0o644,
| HandleKind::Recover
| HandleKind::Timing => MODE_FILE | 0o644,
}
}
@@ -0,0 +1,869 @@
//! Boot race instrumentation: latency metrics for driver-manager.
//!
//! Records per-bucket latency measurements and aggregates them into
//! p50/p95/p99/max summaries. The four buckets are:
//!
//! | Bucket | What it measures |
//! |-------------------|---------------------------------------------------------------|
//! | `claim-spawn` | Claim-to-spawn latency per device: from the start of the |
//! | | claim process to the successful spawn of the child daemon. |
//! | `firmware-ready` | Firmware-loader ready-to-driver-bind latency for drivers with |
//! | | the `NEED_FIRMWARE` quirk: from the first firmware-defer to |
//! | | the successful bind on retry. |
//! | `governor-switch` | Thermald governor-switch latency: from thermald writing to |
//! | | `/scheme/cpufreq/governor` to cpufreqd applying the new |
//! | | governor. (Recorded by cpufreqd when it is wired in.) |
//! | `aer-event` | Pcid AER event latency: from pcid's poller emitting the event |
//! | | to driver-manager's unified listener processing it. |
//!
//! Counter updates (count, sum, min, max) are lock-free via `AtomicU64`.
//! Percentile computation requires storing samples; this is done under a
//! brief `Mutex` lock capped at [`MAX_SAMPLES`]. A ring buffer of the most
//! recent [`RING_SIZE`] measurements is kept for postmortem inspection.
//!
//! Summaries are served via `/scheme/driver-manager/timing` (read-only JSON).
use std::collections::{HashMap, VecDeque};
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
/// Maximum number of samples retained per bucket for percentile computation.
const MAX_SAMPLES: usize = 4096;
/// Number of entries retained in the per-bucket ring buffer for postmortem.
const RING_SIZE: usize = 256;
// ── LatencyMetric ─────────────────────────────────────────────────
/// Aggregated latency metric for one bucket. Returned by [`summary`] and
/// serialized by [`format_json`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LatencyMetric {
pub bucket: &'static str,
pub count: u64,
pub sum_us: u64,
pub min_us: u64,
pub max_us: u64,
pub p50_us: u64,
pub p95_us: u64,
pub p99_us: u64,
}
// ── Bucket ────────────────────────────────────────────────────────
/// One latency measurement bucket.
///
/// Updates to `count`/`sum_us`/`min_us`/`max_us` are lock-free
/// (`AtomicU64`). Samples for percentile computation are stored under a
/// `Mutex`, capped at [`MAX_SAMPLES`]. A ring buffer of the most recent
/// [`RING_SIZE`] measurements is kept for postmortem inspection.
struct Bucket {
count: AtomicU64,
sum_us: AtomicU64,
min_us: AtomicU64,
max_us: AtomicU64,
samples: Mutex<Vec<u64>>,
ring: Mutex<VecDeque<u64>>,
}
impl Bucket {
const fn new() -> Self {
Self {
count: AtomicU64::new(0),
sum_us: AtomicU64::new(0),
min_us: AtomicU64::new(u64::MAX),
max_us: AtomicU64::new(0),
samples: Mutex::new(Vec::new()),
ring: Mutex::new(VecDeque::new()),
}
}
/// Record a latency measurement (in microseconds) into this bucket.
fn record(&self, latency_us: u64) {
self.count.fetch_add(1, Ordering::Relaxed);
// Saturating add for sum — prevents overflow on extreme counts.
let _ = self.sum_us.fetch_update(
Ordering::Relaxed,
Ordering::Relaxed,
|v| Some(v.saturating_add(latency_us)),
);
// fetch_min / fetch_max update atomically.
let _ = self.min_us.fetch_min(latency_us, Ordering::Relaxed);
let _ = self.max_us.fetch_max(latency_us, Ordering::Relaxed);
// Store sample for percentile computation. If the buffer is full,
// we stop appending — the existing MAX_SAMPLES are sufficient for
// stable percentile estimates. A reservoir-sampling scheme would
// be more representative for very long runs but adds complexity.
if let Ok(mut samples) = self.samples.lock() {
if samples.len() < MAX_SAMPLES {
samples.push(latency_us);
}
}
// Ring buffer: always keeps the most recent RING_SIZE values.
if let Ok(mut ring) = self.ring.lock() {
if ring.len() >= RING_SIZE {
ring.pop_front();
}
ring.push_back(latency_us);
}
}
/// Snapshot this bucket into a [`LatencyMetric`].
fn snapshot(&self, name: &'static str) -> LatencyMetric {
let count = self.count.load(Ordering::Relaxed);
let sum_us = self.sum_us.load(Ordering::Relaxed);
let mut min_us = self.min_us.load(Ordering::Relaxed);
let max_us = self.max_us.load(Ordering::Relaxed);
// If no samples were recorded, min is still u64::MAX.
if count == 0 || min_us == u64::MAX {
min_us = 0;
}
if count == 0 {
return LatencyMetric {
bucket: name,
count: 0,
sum_us: 0,
min_us: 0,
max_us: 0,
p50_us: 0,
p95_us: 0,
p99_us: 0,
};
}
let (p50, p95, p99) = match self.samples.lock() {
Ok(samples) => {
if samples.is_empty() {
(0u64, 0u64, 0u64)
} else {
let mut sorted = samples.clone();
sorted.sort_unstable();
let n = sorted.len();
(
sorted[percentile_index(50, n)],
sorted[percentile_index(95, n)],
sorted[percentile_index(99, n)],
)
}
}
Err(_) => (0, 0, 0),
};
LatencyMetric {
bucket: name,
count,
sum_us,
min_us,
max_us,
p50_us: p50,
p95_us: p95,
p99_us: p99,
}
}
/// Return a snapshot of the ring buffer (most recent measurements first).
#[allow(dead_code)]
fn recent(&self) -> Vec<u64> {
match self.ring.lock() {
Ok(ring) => ring.iter().rev().copied().collect(),
Err(_) => Vec::new(),
}
}
}
/// Compute the 0-indexed position for the P-th percentile in a sorted
/// array of `n` elements using the nearest-rank method.
///
/// Nearest-rank: the P-th percentile is the value at ordinal position
/// `ceil(P/100 × N)` (1-indexed). Returns the 0-indexed equivalent,
/// clamped to `[0, n-1]`.
fn percentile_index(percentile: u64, n: usize) -> usize {
if n == 0 {
return 0;
}
// ceil(percentile * n / 100) using integer arithmetic:
// (a + b - 1) / b == ceil(a / b)
let product = percentile * n as u64;
let ordinal = (product + 99) / 100;
(ordinal as usize).saturating_sub(1).min(n - 1)
}
// ── Global registry ───────────────────────────────────────────────
static BUCKET_CLAIM_SPAWN: Bucket = Bucket::new();
static BUCKET_FIRMWARE_READY: Bucket = Bucket::new();
static BUCKET_GOVERNOR_SWITCH: Bucket = Bucket::new();
static BUCKET_AER_EVENT: Bucket = Bucket::new();
/// All bucket names in canonical (output) order.
#[allow(dead_code)]
pub const BUCKET_NAMES: &[&str] = &[
"claim-spawn",
"firmware-ready",
"governor-switch",
"aer-event",
];
/// Record a latency measurement (in microseconds) into the named bucket.
///
/// Unknown bucket names are silently ignored (logged at `trace` level)
/// so a typo never crashes the probe path.
pub fn record(bucket: &str, latency_us: u64) {
match bucket {
"claim-spawn" => BUCKET_CLAIM_SPAWN.record(latency_us),
"firmware-ready" => BUCKET_FIRMWARE_READY.record(latency_us),
"governor-switch" => BUCKET_GOVERNOR_SWITCH.record(latency_us),
"aer-event" => BUCKET_AER_EVENT.record(latency_us),
other => log::trace!("timing: unknown bucket '{other}'"),
}
}
/// Snapshot all buckets into metric summaries, in canonical bucket order.
pub fn summary() -> Vec<LatencyMetric> {
vec![
BUCKET_CLAIM_SPAWN.snapshot("claim-spawn"),
BUCKET_FIRMWARE_READY.snapshot("firmware-ready"),
BUCKET_GOVERNOR_SWITCH.snapshot("governor-switch"),
BUCKET_AER_EVENT.snapshot("aer-event"),
]
}
/// Format all buckets as JSON for `/scheme/driver-manager/timing`.
///
/// Output format (v1):
/// ```json
/// {
/// "version": 1,
/// "buckets": {
/// "claim-spawn": { "count": 0, "min_us": 0, ... },
/// ...
/// }
/// }
/// ```
pub fn format_json() -> String {
format_metrics_json(&summary())
}
/// Format a slice of [`LatencyMetric`] as JSON. Pure function — no global
/// state reads. Used by [`format_json`] and by tests for golden snapshots.
pub fn format_metrics_json(metrics: &[LatencyMetric]) -> String {
let mut out = String::from(r#"{"version":1,"buckets":{"#);
let mut first = true;
for m in metrics {
if !first {
out.push(',');
}
first = false;
out.push_str(&format!(
r#""{bucket}":{{"count":{count},"min_us":{min_us},"max_us":{max_us},"p50_us":{p50_us},"p95_us":{p95_us},"p99_us":{p99_us},"sum_us":{sum_us}}}"#,
bucket = m.bucket,
count = m.count,
min_us = m.min_us,
max_us = m.max_us,
p50_us = m.p50_us,
p95_us = m.p95_us,
p99_us = m.p99_us,
sum_us = m.sum_us,
));
}
out.push_str("}}");
out
}
// ── Firmware-defer tracking ───────────────────────────────────────
//
// Tracks the first time a device was deferred due to NEED_FIRMWARE, so
// that when the same device eventually binds (firmware now available),
// we can measure the firmware-ready latency. Uses a per-device map under
// a Mutex. The Mutex wraps an Option<HashMap> because HashMap::new() is
// not const-evaluable in a static initializer.
static FIRMWARE_DEFER_STARTS: Mutex<Option<HashMap<String, Instant>>> = Mutex::new(None);
/// Record the start of a firmware-related deferral for a device.
///
/// Only the *first* deferral is recorded — subsequent retries for the
/// same device do not reset the timer, so the measured latency spans
/// from the original NEED_FIRMWARE detection to the eventual bind.
pub fn record_firmware_defer_start(device_key: &str) {
if let Ok(mut starts) = FIRMWARE_DEFER_STARTS.lock() {
let map = starts.get_or_insert_with(HashMap::new);
map.entry(device_key.to_string())
.or_insert_with(Instant::now);
}
}
/// If the device was previously firmware-deferred, record the
/// firmware-ready latency (from the first defer to now) and remove it
/// from the tracking map.
///
/// Returns `true` if a measurement was recorded, `false` if the device
/// was not previously firmware-deferred.
pub fn record_firmware_ready_if_deferred(device_key: &str) -> bool {
if let Ok(mut starts) = FIRMWARE_DEFER_STARTS.lock() {
if let Some(map) = starts.as_mut() {
if let Some(start) = map.remove(device_key) {
let elapsed_us = start.elapsed().as_micros() as u64;
record("firmware-ready", elapsed_us);
return true;
}
}
}
false
}
// ── AER timestamp parsing ─────────────────────────────────────────
//
// pcid's AER producer emits lines with a `ts=<rfc3339>` field giving
// the wall-clock time the poller observed the error. By comparing that
// to the current wall-clock time, we measure the poller→consumer
// latency of the unified event listener.
/// Parse a `ts=<rfc3339>` field from a pcid AER event line and compute
/// the latency from that timestamp to now (in microseconds).
///
/// Returns `None` if no parseable `ts=` field is present, or if the
/// computed latency would be negative (clock skew / stale event).
pub fn parse_aer_latency_us(raw: &str) -> Option<u64> {
let ts_str = parse_ts_field(raw)?;
let epoch_us = parse_rfc3339_to_epoch_micros(&ts_str)?;
let now_us = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_micros() as u64;
if now_us > epoch_us {
Some(now_us - epoch_us)
} else {
None
}
}
/// Extract the value of `ts=...` from a space-delimited event line.
///
/// Returns only the RFC3339 timestamp portion (`YYYY-MM-DDTHH:MM:SS[.fff][Z]`),
/// stripping any trailing field content that shares the same whitespace-delimited
/// token (pcid uses `:` as a field separator, which collides with timestamp colons).
fn parse_ts_field(raw: &str) -> Option<String> {
for token in raw.split_whitespace() {
if let Some(v) = token.strip_prefix("ts=") {
return Some(extract_rfc3339_prefix(v).to_string());
}
}
None
}
/// Extract the RFC3339 timestamp prefix from a string that may contain
/// additional colon-separated fields (e.g. `2026-07-25T12:34:56:severity=...`).
fn extract_rfc3339_prefix(s: &str) -> &str {
let bytes = s.as_bytes();
if bytes.len() < 19 {
return s;
}
// Verify the YYYY-MM-DDTHH:MM:SS structure at known delimiter positions.
if bytes[4] != b'-'
|| bytes[7] != b'-'
|| bytes[10] != b'T'
|| bytes[13] != b':'
|| bytes[16] != b':'
{
return s;
}
let mut end = 19;
// Optional fractional seconds: .fff...
if end < bytes.len() && bytes[end] == b'.' {
end += 1;
while end < bytes.len() && bytes[end].is_ascii_digit() {
end += 1;
}
}
// Optional trailing Z.
if end < bytes.len() && bytes[end] == b'Z' {
end += 1;
}
&s[..end]
}
/// Parse an RFC3339-ish timestamp `YYYY-MM-DDTHH:MM:SS[.fff][Z]` into
/// microseconds since the Unix epoch.
///
/// Handles the subset of RFC3339 that pcid produces: date-time with
/// optional fractional seconds and optional trailing `Z`. Timezone
/// offsets are not supported (pcid emits wall-clock UTC without offset).
///
/// Returns `None` on any parse failure.
fn parse_rfc3339_to_epoch_micros(ts: &str) -> Option<u64> {
let bytes = ts.as_bytes();
// Minimum: "YYYY-MM-DDTHH:MM:SS" = 19 chars
if bytes.len() < 19 {
return None;
}
// Parse fixed-width date-time fields with delimiter checks.
let year: u32 = std::str::from_utf8(&bytes[0..4]).ok()?.parse().ok()?;
if bytes[4] != b'-' {
return None;
}
let month: u32 = std::str::from_utf8(&bytes[5..7]).ok()?.parse().ok()?;
if bytes[7] != b'-' {
return None;
}
let day: u32 = std::str::from_utf8(&bytes[8..10]).ok()?.parse().ok()?;
if bytes[10] != b'T' {
return None;
}
let hour: u32 = std::str::from_utf8(&bytes[11..13]).ok()?.parse().ok()?;
if bytes[13] != b':' {
return None;
}
let minute: u32 = std::str::from_utf8(&bytes[14..16]).ok()?.parse().ok()?;
if bytes[16] != b':' {
return None;
}
let second: u32 = std::str::from_utf8(&bytes[17..19]).ok()?.parse().ok()?;
// Validate ranges.
if month == 0 || month > 12 || day == 0 || day > 31 || hour > 23 || minute > 59 || second > 60 {
return None;
}
// Parse optional fractional seconds (up to 6 digits → microseconds).
let mut micros: u32 = 0;
let mut idx = 19;
if idx < bytes.len() && bytes[idx] == b'.' {
idx += 1;
let frac_start = idx;
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
idx += 1;
}
let frac_str = std::str::from_utf8(&bytes[frac_start..idx]).ok()?;
// Pad or truncate to exactly 6 digits.
let mut padded = format!("{frac_str:0<6}");
padded.truncate(6);
micros = padded.parse().ok()?;
}
// Skip optional 'Z' suffix (we assume UTC).
// Any trailing timezone offset is ignored — pcid emits UTC.
let days = civil_to_days(year, month, day)?;
if days < 0 {
return None;
}
let epoch_seconds = days as u64 * 86_400 + hour as u64 * 3_600 + minute as u64 * 60 + second as u64;
Some(epoch_seconds * 1_000_000 + micros as u64)
}
/// Convert a Gregorian calendar date to days since 1970-01-01.
///
/// Uses Howard Hinnant's `days_from_civil` algorithm — a closed-form
/// conversion that is correct for all dates in the proleptic Gregorian
/// calendar. Returns `None` for invalid dates.
fn civil_to_days(y: u32, m: u32, d: u32) -> Option<i64> {
if m == 0 || m > 12 || d == 0 || d > 31 {
return None;
}
let y = y as i64;
let y_adj = if m <= 2 { y - 1 } else { y };
let era = y_adj.div_euclid(400);
let yoe = y_adj.rem_euclid(400) as u64; // [0, 399]
let m_shift = if m > 2 { m - 3 } else { m + 9 } as u64; // [0, 11]
let doy = (153 * m_shift + 2) / 5 + d as u64 - 1; // [0, 365]
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
let days = era * 146_097 + doe as i64 - 719_468;
Some(days)
}
// ── Tests ─────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::thread;
// ── Single record + summary ──────────────────────────────────
#[test]
fn single_record_then_summary() {
let bucket = Bucket::new();
bucket.record(5_000);
let m = bucket.snapshot("test");
assert_eq!(m.bucket, "test");
assert_eq!(m.count, 1);
assert_eq!(m.sum_us, 5_000);
assert_eq!(m.min_us, 5_000);
assert_eq!(m.max_us, 5_000);
assert_eq!(m.p50_us, 5_000);
assert_eq!(m.p95_us, 5_000);
assert_eq!(m.p99_us, 5_000);
}
// ── Empty bucket summary ─────────────────────────────────────
#[test]
fn empty_bucket_summary() {
let bucket = Bucket::new();
let m = bucket.snapshot("empty");
assert_eq!(m.count, 0);
assert_eq!(m.sum_us, 0);
assert_eq!(m.min_us, 0);
assert_eq!(m.max_us, 0);
assert_eq!(m.p50_us, 0);
assert_eq!(m.p95_us, 0);
assert_eq!(m.p99_us, 0);
}
// ── 100 records → percentile ordering ────────────────────────
#[test]
fn hundred_records_percentile_ordering() {
let bucket = Bucket::new();
// Record values 1..=100 so percentiles are deterministic.
for v in 1..=100u64 {
bucket.record(v);
}
let m = bucket.snapshot("hundred");
assert_eq!(m.count, 100);
assert_eq!(m.min_us, 1);
assert_eq!(m.max_us, 100);
assert_eq!(m.sum_us, 5050); // sum of 1..=100
// Nearest-rank percentiles for 1..=100:
// p50: ceil(50/100 * 100) = 50 → 0-indexed 49 → value 50
// p95: ceil(95/100 * 100) = 95 → 0-indexed 94 → value 95
// p99: ceil(99/100 * 100) = 99 → 0-indexed 98 → value 99
assert_eq!(m.p50_us, 50);
assert_eq!(m.p95_us, 95);
assert_eq!(m.p99_us, 99);
// Ordering invariant: min ≤ p50 ≤ p95 ≤ p99 ≤ max
assert!(m.min_us <= m.p50_us);
assert!(m.p50_us <= m.p95_us);
assert!(m.p95_us <= m.p99_us);
assert!(m.p99_us <= m.max_us);
}
#[test]
fn percentile_ordering_with_skewed_values() {
let bucket = Bucket::new();
// 98 values of 10, 1 value of 5000, 1 value of 10000
for _ in 0..98 {
bucket.record(10);
}
bucket.record(5_000);
bucket.record(10_000);
let m = bucket.snapshot("skewed");
assert_eq!(m.count, 100);
// p50 should be 10 (98% of values are 10)
assert_eq!(m.p50_us, 10);
// p95 should be 5000 (95th value in sorted order)
assert!(m.p95_us >= 10);
assert!(m.p95_us <= m.p99_us);
}
// ── Concurrent records ───────────────────────────────────────
#[test]
fn concurrent_records_same_bucket() {
let bucket = Arc::new(Bucket::new());
let num_threads = 8;
let per_thread = 500u64;
let handles: Vec<_> = (0..num_threads)
.map(|_| {
let b = Arc::clone(&bucket);
thread::spawn(move || {
for i in 0..per_thread {
b.record(1_000 + i);
}
})
})
.collect();
for h in handles {
h.join().expect("thread panicked");
}
let m = bucket.snapshot("concurrent");
assert_eq!(m.count, num_threads as u64 * per_thread);
assert!(m.min_us >= 1_000);
assert!(m.max_us <= 1_000 + per_thread - 1);
// Ordering must hold even under concurrency.
assert!(m.min_us <= m.p50_us);
assert!(m.p50_us <= m.p95_us);
assert!(m.p95_us <= m.p99_us);
assert!(m.p99_us <= m.max_us);
}
// ── Ring buffer ──────────────────────────────────────────────
#[test]
fn ring_buffer_caps_at_ring_size() {
let bucket = Bucket::new();
for i in 0..(RING_SIZE + 100) as u64 {
bucket.record(i);
}
let recent = bucket.recent();
assert_eq!(recent.len(), RING_SIZE);
// Most recent value should be the last recorded.
assert_eq!(recent[0], (RING_SIZE + 100 - 1) as u64);
}
// ── Percentile index helper ──────────────────────────────────
#[test]
fn percentile_index_basic() {
assert_eq!(percentile_index(50, 100), 49);
assert_eq!(percentile_index(95, 100), 94);
assert_eq!(percentile_index(99, 100), 98);
}
#[test]
fn percentile_index_small_n() {
assert_eq!(percentile_index(50, 1), 0);
assert_eq!(percentile_index(95, 1), 0);
// n=2: p50 = ceil(0.50 * 2) = 1 (1-indexed) → 0-indexed 0
assert_eq!(percentile_index(50, 2), 0);
// n=2: p95 = ceil(0.95 * 2) = 2 (1-indexed) → 0-indexed 1
assert_eq!(percentile_index(95, 2), 1);
assert_eq!(percentile_index(95, 10), 9);
}
#[test]
fn percentile_index_zero_n() {
assert_eq!(percentile_index(50, 0), 0);
assert_eq!(percentile_index(99, 0), 0);
}
// ── JSON format stability (golden snapshot) ──────────────────
#[test]
fn json_format_empty_metrics() {
let metrics = vec![
LatencyMetric {
bucket: "claim-spawn",
count: 0,
sum_us: 0,
min_us: 0,
max_us: 0,
p50_us: 0,
p95_us: 0,
p99_us: 0,
},
LatencyMetric {
bucket: "firmware-ready",
count: 0,
sum_us: 0,
min_us: 0,
max_us: 0,
p50_us: 0,
p95_us: 0,
p99_us: 0,
},
];
let json = format_metrics_json(&metrics);
assert!(json.starts_with(r#"{"version":1,"buckets":{"#));
assert!(json.ends_with("}}"));
assert!(json.contains(r#""claim-spawn":{"count":0"#));
assert!(json.contains(r#""firmware-ready":{"count":0"#));
}
#[test]
fn json_format_populated_metrics() {
let metrics = vec![LatencyMetric {
bucket: "aer-event",
count: 5,
sum_us: 12_000,
min_us: 612,
max_us: 4_523,
p50_us: 2_100,
p95_us: 4_523,
p99_us: 4_523,
}];
let json = format_metrics_json(&metrics);
let expected = r#"{"version":1,"buckets":{"aer-event":{"count":5,"min_us":612,"max_us":4523,"p50_us":2100,"p95_us":4523,"p99_us":4523,"sum_us":12000}}}"#;
assert_eq!(json, expected);
}
#[test]
fn json_format_all_four_buckets() {
// Build a snapshot of all four canonical buckets with realistic data.
let metrics: Vec<LatencyMetric> = BUCKET_NAMES
.iter()
.map(|&name| LatencyMetric {
bucket: name,
count: 0,
sum_us: 0,
min_us: 0,
max_us: 0,
p50_us: 0,
p95_us: 0,
p99_us: 0,
})
.collect();
let json = format_metrics_json(&metrics);
// All four bucket names must appear in canonical order.
let cs = json.find(r#""claim-spawn""#).unwrap();
let fr = json.find(r#""firmware-ready""#).unwrap();
let gs = json.find(r#""governor-switch""#).unwrap();
let ae = json.find(r#""aer-event""#).unwrap();
assert!(cs < fr);
assert!(fr < gs);
assert!(gs < ae);
}
// ── Global record/summary/format_json ────────────────────────
#[test]
fn global_record_and_format_json_produces_valid_structure() {
// Record into a global bucket. We can't reset globals between
// tests, so we only check structural validity, not exact counts.
record("claim-spawn", 42_000);
record("claim-spawn", 99_000);
let json = format_json();
assert!(json.starts_with(r#"{"version":1,"buckets":{"#));
assert!(json.contains(r#""claim-spawn""#));
assert!(json.contains(r#""firmware-ready""#));
assert!(json.contains(r#""governor-switch""#));
assert!(json.contains(r#""aer-event""#));
assert!(json.ends_with("}}"));
}
#[test]
fn global_record_unknown_bucket_ignored() {
// Should not panic.
record("nonexistent-bucket", 1_000);
}
// ── Firmware-defer tracking ──────────────────────────────────
#[test]
fn firmware_defer_records_on_first_defer_only() {
// Use a unique device key per test invocation to avoid interference
// with other tests that might touch the global map.
let key = format!(
"fw-defer-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
// Before recording, no deferred device.
assert!(!record_firmware_ready_if_deferred(&key));
// Record defer start.
record_firmware_defer_start(&key);
// Recording again should not overwrite the original timestamp.
std::thread::sleep(std::time::Duration::from_micros(100));
record_firmware_defer_start(&key);
// Now resolving should return true and record a measurement.
assert!(record_firmware_ready_if_deferred(&key));
// Second resolution should return false (already consumed).
assert!(!record_firmware_ready_if_deferred(&key));
}
// ── AER timestamp parsing ────────────────────────────────────
#[test]
fn parse_ts_field_extracts_value() {
// This is the post-seq-stripped form that event.raw contains
// (parse_seq_prefix strips "seq=N:" before AerEvent::parse sees it).
let raw = "ts=2026-07-25T12:34:56:severity=NonFatal device=0000:00:1f.2";
assert_eq!(
parse_ts_field(raw).unwrap(),
"2026-07-25T12:34:56"
);
}
#[test]
fn parse_ts_field_returns_none_when_absent() {
assert!(parse_ts_field("seq=42:severity=NonFatal device=d").is_none());
assert!(parse_ts_field("").is_none());
}
#[test]
fn parse_rfc3339_known_epoch() {
// 1970-01-01T00:00:00 = epoch 0
assert_eq!(parse_rfc3339_to_epoch_micros("1970-01-01T00:00:00"), Some(0));
// 1970-01-01T00:00:01 = 1 second = 1_000_000 micros
assert_eq!(
parse_rfc3339_to_epoch_micros("1970-01-01T00:00:01"),
Some(1_000_000)
);
// 1970-01-02T00:00:00 = 1 day = 86_400 seconds
assert_eq!(
parse_rfc3339_to_epoch_micros("1970-01-02T00:00:00"),
Some(86_400 * 1_000_000)
);
// 2026-07-25T12:00:00 — verify it's a plausible large number
let v = parse_rfc3339_to_epoch_micros("2026-07-25T12:00:00").unwrap();
// 2026 is ~56 years after 1970 → ~56 * 365.25 * 86400 seconds
let approx_seconds = v / 1_000_000;
assert!(approx_seconds > 56 * 365 * 86_400);
assert!(approx_seconds < 57 * 366 * 86_400);
}
#[test]
fn parse_rfc3339_with_fractional_seconds() {
let whole = parse_rfc3339_to_epoch_micros("1970-01-01T00:00:01").unwrap();
let frac = parse_rfc3339_to_epoch_micros("1970-01-01T00:00:01.500000").unwrap();
assert_eq!(frac - whole, 500_000); // 0.5 second = 500_000 micros
}
#[test]
fn parse_rfc3339_with_trailing_z() {
assert_eq!(
parse_rfc3339_to_epoch_micros("1970-01-01T00:00:00Z"),
Some(0)
);
}
#[test]
fn parse_rfc3339_rejects_malformed() {
assert!(parse_rfc3339_to_epoch_micros("").is_none());
assert!(parse_rfc3339_to_epoch_micros("not-a-date").is_none());
assert!(parse_rfc3339_to_epoch_micros("2026/07/25 12:00:00").is_none());
assert!(parse_rfc3339_to_epoch_micros("2026-13-01T00:00:00").is_none()); // month 13
assert!(parse_rfc3339_to_epoch_micros("2026-00-01T00:00:00").is_none()); // month 0
}
#[test]
fn parse_aer_latency_returns_none_without_ts() {
assert!(parse_aer_latency_us("seq=42:severity=NonFatal device=d").is_none());
}
#[test]
fn civil_to_days_known_values() {
// 1970-01-01 = 0 days since epoch
assert_eq!(civil_to_days(1970, 1, 1), Some(0));
// 1970-01-02 = 1 day
assert_eq!(civil_to_days(1970, 1, 2), Some(1));
// 1969-12-31 = -1 day
assert_eq!(civil_to_days(1969, 12, 31), Some(-1));
// 2024-01-01 = 19723 days (known value)
assert_eq!(civil_to_days(2024, 1, 1), Some(19_723));
// 2024-03-01 = 19783 (leap year Jan has 31 days)
assert_eq!(civil_to_days(2024, 3, 1), Some(19_783));
}
#[test]
fn civil_to_days_rejects_invalid() {
assert!(civil_to_days(2026, 0, 1).is_none());
assert!(civil_to_days(2026, 13, 1).is_none());
assert!(civil_to_days(2026, 7, 0).is_none());
assert!(civil_to_days(2026, 7, 32).is_none());
}
}
@@ -101,13 +101,25 @@ fn run<F>(
let binds = bind_snapshot();
for event in events {
let action = crate::aer::route_to_driver(&event, &binds, &consult_driver);
log::info!(
"AER: event device={} severity={:?} action={:?} raw={}",
event.device,
event.severity,
action,
event.raw,
);
if let Some(latency_us) = crate::timing::parse_aer_latency_us(&event.raw) {
crate::timing::record("aer-event", latency_us);
log::info!(
"AER: event device={} severity={:?} action={:?} latency={}us raw={}",
event.device,
event.severity,
action,
latency_us,
event.raw,
);
} else {
log::info!(
"AER: event device={} severity={:?} action={:?} raw={}",
event.device,
event.severity,
action,
event.raw,
);
}
handle_event(&UnifiedEvent::Aer { event, action });
}
}