use std::num::ParseIntError;
#[derive(Debug)]
pub enum ParseError {
NoCommTerminator,
TooFewFields { got: usize },
UtimeNotU64(ParseIntError),
StimeNotU64(ParseIntError),
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParseError::NoCommTerminator => {
write!(
f,
"no `)` in /proc/<pid>/stat line — comm field unterminated"
)
}
ParseError::TooFewFields { got } => write!(
f,
"post-comm tail has only {got} whitespace-separated fields; \
need at least 13 to reach utime + stime"
),
ParseError::UtimeNotU64(e) => write!(f, "utime not parseable as u64: {e}"),
ParseError::StimeNotU64(e) => write!(f, "stime not parseable as u64: {e}"),
}
}
}
impl std::error::Error for ParseError {}
pub fn parse_proc_stat_cpu_jiffies(stat: &str) -> Result<(u64, u64), ParseError> {
let close = stat.rfind(')').ok_or(ParseError::NoCommTerminator)?;
let tail = &stat[close + 1..];
let fields: Vec<&str> = tail.split_ascii_whitespace().collect();
if fields.len() < 13 {
return Err(ParseError::TooFewFields { got: fields.len() });
}
let utime = fields[11].parse::<u64>().map_err(ParseError::UtimeNotU64)?;
let stime = fields[12].parse::<u64>().map_err(ParseError::StimeNotU64)?;
Ok((utime, stime))
}
pub fn compute_cpu_seconds_used(
stat_before: (u64, u64),
stat_after: (u64, u64),
clock_ticks_per_sec: u64,
) -> f64 {
debug_assert!(
clock_ticks_per_sec > 0,
"clock_ticks_per_sec must be > 0 (sysconf(_SC_CLK_TCK))"
);
let du = stat_after.0.saturating_sub(stat_before.0);
let ds = stat_after.1.saturating_sub(stat_before.1);
let total_ticks = du.saturating_add(ds);
total_ticks as f64 / clock_ticks_per_sec as f64
}
pub fn quota_enforced(
cpu_seconds_used: f64,
vcpu_count: u32,
wall_clock_seconds: f64,
tolerance_pct: f64,
) -> bool {
let budget = f64::from(vcpu_count) * wall_clock_seconds * (1.0 + tolerance_pct / 100.0);
cpu_seconds_used <= budget
}
#[cfg(test)]
mod parser {
use super::*;
fn synth(comm: &str, utime: u64, stime: u64) -> String {
format!("1 ({comm}) S 0 1 1 0 -1 4194304 100 0 0 0 {utime} {stime} 0 0 20 0 1 0 0",)
}
#[test]
fn typical_firecracker_line_parses() {
let line = synth("firecracker", 123, 45);
assert_eq!(parse_proc_stat_cpu_jiffies(&line).unwrap(), (123, 45));
}
#[test]
fn comm_with_embedded_space_parses() {
let line = synth("multi word", 200, 50);
assert_eq!(parse_proc_stat_cpu_jiffies(&line).unwrap(), (200, 50));
}
#[test]
fn comm_with_embedded_paren_picks_last_close() {
let line = "42 (weird) name) R 0 1 1 0 -1 4 0 0 0 0 7 11 0 0 20 0";
assert_eq!(parse_proc_stat_cpu_jiffies(line).unwrap(), (7, 11));
}
#[test]
fn missing_close_paren_is_error() {
let line = "1 firecracker S 0 1 1 0 -1 4 0 0 0 0 1 2 0 0 20 0";
let err = parse_proc_stat_cpu_jiffies(line).expect_err("must error");
assert!(matches!(err, ParseError::NoCommTerminator), "got: {err}");
}
#[test]
fn too_few_fields_is_error() {
let line = "1 (fc) R 0 1 1 0";
let err = parse_proc_stat_cpu_jiffies(line).expect_err("must error");
assert!(matches!(err, ParseError::TooFewFields { .. }), "got: {err}");
}
#[test]
fn non_numeric_utime_is_error() {
let line = "1 (fc) S 0 1 1 0 -1 4 0 0 0 0 NOPE 5 0 0 20 0 1 0 0";
let err = parse_proc_stat_cpu_jiffies(line).expect_err("must error");
assert!(matches!(err, ParseError::UtimeNotU64(_)), "got: {err}");
}
#[test]
fn non_numeric_stime_is_error() {
let line = "1 (fc) S 0 1 1 0 -1 4 0 0 0 0 7 NOPE 0 0 20 0 1 0 0";
let err = parse_proc_stat_cpu_jiffies(line).expect_err("must error");
assert!(matches!(err, ParseError::StimeNotU64(_)), "got: {err}");
}
}
#[cfg(test)]
mod arithmetic {
use super::*;
#[test]
fn cpu_seconds_simple_delta() {
let s = compute_cpu_seconds_used((100, 50), (200, 80), 100);
assert!((s - 1.30).abs() < 1e-9, "got {s}");
}
#[test]
fn cpu_seconds_zero_delta() {
let s = compute_cpu_seconds_used((42, 7), (42, 7), 100);
assert_eq!(s, 0.0);
}
#[test]
fn cpu_seconds_swapped_snapshots_saturate_to_zero() {
let s = compute_cpu_seconds_used((200, 80), (100, 50), 100);
assert_eq!(s, 0.0);
}
#[test]
fn cpu_seconds_typical_kernel_clock_tck_100() {
let s = compute_cpu_seconds_used((0, 0), (700, 300), 100);
assert!((s - 10.0).abs() < 1e-9, "got {s}");
}
}
#[cfg(test)]
mod typed_reason_contract {
#[test]
fn fc53_typed_reason_locks_signal_killed_wire_string() {
let typed_reason = cellos_core::LifecycleReason::SignalKilled;
assert_eq!(
typed_reason.as_wire_str(),
"signal_killed",
"FC-50 typed enum's wire form for SignalKilled must equal the \
canonical `signal_killed` token. A rename of the variant or a \
serde-shape drift surfaces here as a unit failure rather than \
going unnoticed until a supervisor-emission integration leg \
flips the wire bytes. FC-53's vCPU-quota busted path \
(supervisor SIGKILLs the firecracker process whose threads \
escaped the cgroup cpu.max budget) lands as \
`reason = signal_killed` once FC-50 wiring reaches this code \
path — see the FC-52 sibling assertion for the OOM equivalent."
);
}
}
#[cfg(test)]
mod quota {
use super::*;
#[test]
fn one_vcpu_under_budget_is_ok() {
assert!(quota_enforced(4.5, 1, 5.0, 10.0));
}
#[test]
fn one_vcpu_at_budget_is_ok() {
assert!(quota_enforced(11.0, 1, 10.0, 10.0));
}
#[test]
fn one_vcpu_just_over_tolerance_breaks_quota() {
assert!(!quota_enforced(11.001, 1, 10.0, 10.0));
}
#[test]
fn one_vcpu_used_eight_breaks_quota_under_strict_tolerance() {
assert!(!quota_enforced(8.0, 1, 5.0, 0.0));
}
#[test]
fn two_vcpus_used_eight_under_ten_seconds_is_ok() {
assert!(quota_enforced(8.0, 2, 10.0, 0.0));
}
#[test]
fn fc53_canonical_n_plus_one_threads_one_vcpu_pass() {
assert!(quota_enforced(9.5, 1, 10.0, 10.0));
}
#[test]
fn fc53_canonical_n_plus_one_threads_one_vcpu_fail() {
assert!(!quota_enforced(18.0, 1, 10.0, 10.0));
}
#[test]
fn zero_vcpu_zero_budget() {
assert!(quota_enforced(0.0, 0, 10.0, 10.0));
assert!(!quota_enforced(0.001, 0, 10.0, 10.0));
}
}
#[cfg(target_os = "linux")]
#[test]
#[ignore = "requires firecracker binary, kernel, rootfs, and KVM; run with --ignored"]
fn fc53_vcpu_quota_one_vcpu_n_plus_one_threads_10s_window() {
use std::fs;
use std::time::{Duration, Instant};
const WALL_CLOCK_SECONDS: f64 = 10.0;
const TOLERANCE_PCT: f64 = 10.0;
const VCPU_COUNT: u32 = 1;
let pid_str = std::env::var("CELLOS_FIRECRACKER_FC53_PID").expect(
"CELLOS_FIRECRACKER_FC53_PID must be the firecracker process PID \
when running this test with --ignored. The e2e workflow exports \
it after spawning the cell with vcpu_count=1 and \
argv=[/bin/sh,-c,yes > /dev/null & yes > /dev/null & sleep 10].",
);
let pid: u32 = pid_str
.trim()
.parse()
.expect("CELLOS_FIRECRACKER_FC53_PID must be a positive integer");
let stat_path = format!("/proc/{pid}/stat");
let clk_tck: u64 = {
let out = std::process::Command::new("getconf")
.arg("CLK_TCK")
.output()
.expect("getconf CLK_TCK failed; required for FC-53 measurement");
let s = String::from_utf8_lossy(&out.stdout);
s.trim()
.parse::<u64>()
.expect("getconf CLK_TCK output is not a u64")
};
let read_stat = || -> (u64, u64) {
let s = fs::read_to_string(&stat_path).unwrap_or_else(|e| {
panic!(
"FC-53: failed to read {stat_path}: {e} \
(firecracker process likely exited before window closed)"
)
});
parse_proc_stat_cpu_jiffies(&s)
.unwrap_or_else(|e| panic!("FC-53: malformed {stat_path}: {e}\nline: {s}"))
};
let started = Instant::now();
let before = read_stat();
std::thread::sleep(Duration::from_secs_f64(WALL_CLOCK_SECONDS));
let after = read_stat();
let elapsed = started.elapsed().as_secs_f64();
let used = compute_cpu_seconds_used(before, after, clk_tck);
let pass = quota_enforced(used, VCPU_COUNT, elapsed, TOLERANCE_PCT);
assert!(
pass,
"FC-53 violation: firecracker pid {pid} consumed {used:.3} CPU-seconds \
over a {elapsed:.3} s wall-clock window with vcpu_count={VCPU_COUNT} \
(tolerance {TOLERANCE_PCT}%). Budget was \
{budget:.3} s. Two-yes-loops workload escaped the vCPU quota — \
the VMM's MachineConfig.vcpu_count is not being honoured by the \
host scheduler, or the cgroup cpu.max is missing.",
budget = f64::from(VCPU_COUNT) * elapsed * (1.0 + TOLERANCE_PCT / 100.0)
);
}