use super::*;
#[test]
fn guard_runtime_state_default_is_empty() {
let state = crate::GuardRuntimeState::default();
assert!(state.heavy_since.is_empty());
assert!(state.notify_cooldowns.is_empty());
assert!(state.last_disk_state.is_empty());
assert!(state.oom_known_descendants.is_empty());
assert!(state.oom_pending_descendants.is_empty());
}
#[test]
fn oom_descendant_candidates_only_select_new_untracked_user_processes() {
let sample = |pid: i32, ppid: i32, starttime: u64, command: &str| ProcSample {
pid,
ppid,
cpu_percent: 0.0,
rss_mb: 1,
nice: 0,
command: command.to_string(),
args: String::new(),
starttime,
};
let samples = vec![
sample(10, 1, 10, "root"),
sample(11, 10, 11, "existing-child"),
sample(12, 10, 12, "new-child"),
sample(13, 12, 13, "new-grandchild"),
sample(14, 1, 14, "unrelated"),
sample(15, 10, 15, "kworker/0:1"),
sample(16, 10, 16, "editor"),
sample(17, 10, 17, "tracked-child"),
];
let known = HashSet::from([(11, 11)]);
let tracked = HashSet::from([10, 17]);
let exempt = HashSet::from(["editor".to_string()]);
let descendants = crate::process_descendant_samples(&samples, 10);
assert_eq!(
descendants
.iter()
.map(|sample| sample.pid)
.collect::<Vec<_>>(),
vec![11, 12, 13, 15, 16, 17]
);
let candidates = crate::oom_descendant_candidates(&samples, 10, &known, &tracked, &exempt);
assert_eq!(
candidates
.iter()
.map(|sample| sample.pid)
.collect::<Vec<_>>(),
vec![12, 13]
);
}
#[test]
fn runtime_adjustment_plan_includes_every_reversible_limiter() {
let mut state = crate::GuardRuntimeState::default();
state.reniced_pids.insert(
101,
crate::LegacyReniceState {
original_nice: 0,
applied_nice: 5,
identity: crate::ProcessIdentity {
comm: "legacy-worker".to_string(),
starttime: 1,
},
},
);
state.memory_reniced_pids.insert(
102,
crate::MemoryReniceState {
original_nice: 3,
applied_nice: 10,
identity: crate::ProcessIdentity {
comm: "memory-worker".to_string(),
starttime: 2,
},
},
);
state.oom_biased_pids.insert(
103,
(
-100,
crate::ProcessIdentity {
comm: "oom-worker".to_string(),
starttime: 3,
},
),
);
state.capped_pids.insert(
104,
(
"dracon-cap.service".to_string(),
"user.slice".to_string(),
crate::ProcessIdentity {
comm: "cap-worker".to_string(),
starttime: 4,
},
),
);
let plan = crate::runtime_adjustment_plan(&state);
assert_eq!(plan.len(), 4);
assert!(plan.contains(&crate::RuntimeAdjustment::Nice {
pid: 101,
original_nice: 0,
identity: crate::ProcessIdentity {
comm: "legacy-worker".to_string(),
starttime: 1,
},
scope: crate::NiceRestoreScope::Legacy,
}));
assert!(plan.contains(&crate::RuntimeAdjustment::Nice {
pid: 102,
original_nice: 3,
identity: crate::ProcessIdentity {
comm: "memory-worker".to_string(),
starttime: 2,
},
scope: crate::NiceRestoreScope::Memory,
}));
assert!(plan.contains(&crate::RuntimeAdjustment::OomBias {
pid: 103,
orig_adj: -100,
identity: crate::ProcessIdentity {
comm: "oom-worker".to_string(),
starttime: 3,
},
}));
assert!(plan.contains(&crate::RuntimeAdjustment::CpuCap {
pid: 104,
scope: "dracon-cap.service".to_string(),
orig_cgroup: "user.slice".to_string(),
identity: crate::ProcessIdentity {
comm: "cap-worker".to_string(),
starttime: 4,
},
}));
}
#[test]
fn guard_runtime_state_insert_and_retrieve_heavy_process() {
let mut state = crate::GuardRuntimeState::default();
state.heavy_since.insert(1234, (Instant::now(), 0));
assert!(state.heavy_since.contains_key(&1234));
state.heavy_since.remove(&1234);
assert!(!state.heavy_since.contains_key(&1234));
}
#[test]
fn guard_policy_disk_thresholds_are_public() {
let guard = GuardPolicy::default();
assert_eq!(guard.disk_warn_percent, 80);
assert_eq!(guard.disk_action_percent, 90);
assert_eq!(guard.disk_critical_percent, 95);
assert_eq!(guard.disk_early_warn_percent, 70);
}
#[test]
fn guard_report_can_be_created_with_alerts() {
use crate::GuardProcessAlert;
use crate::GuardReport;
let report = GuardReport {
enabled: true,
disk_use_percent: 72,
disk_state: "warn".to_string(),
sync_frozen: false,
extra_mounts: Vec::new(),
relocate_candidates: Vec::new(),
relocated_count: 0,
relocated_bytes: 0,
alerts: vec![GuardProcessAlert {
pid: 12345,
ppid: 1,
command: "cargo".to_string(),
args: "build".to_string(),
cpu_percent: 250.0,
rss_mb: 1024,
sustained_secs: 35,
action: "reniced".to_string(),
nice_value: 5,
}],
memory: None,
zombies: Vec::new(),
reap_candidates: Vec::new(),
reaped: Vec::new(),
disk_fill_gbph: None,
};
assert!(report.enabled);
assert_eq!(report.disk_use_percent, 72);
assert_eq!(report.alerts.len(), 1);
assert_eq!(report.alerts[0].pid, 12345);
}
#[test]
fn graduated_nice_value_cpu_tier_180_percent() {
assert_eq!(crate::graduated_nice_value(180.0, 0, 5, 0), 5);
assert_eq!(crate::graduated_nice_value(200.0, 0, 5, 0), 5);
}
#[test]
fn graduated_nice_value_cpu_tier_300_percent() {
assert_eq!(crate::graduated_nice_value(300.0, 0, 5, 0), 10);
assert_eq!(crate::graduated_nice_value(350.0, 0, 5, 0), 10);
}
#[test]
fn graduated_nice_value_cpu_tier_500_percent() {
assert_eq!(crate::graduated_nice_value(500.0, 0, 5, 0), 15);
assert_eq!(crate::graduated_nice_value(600.0, 0, 5, 0), 15);
}
#[test]
fn graduated_nice_value_memory_4gb() {
assert_eq!(crate::graduated_nice_value(0.0, 4096, 5, 0), 5);
}
#[test]
fn graduated_nice_value_memory_8gb() {
assert_eq!(crate::graduated_nice_value(0.0, 8192, 10, 0), 10);
}
#[test]
fn graduated_nice_value_below_all_tiers_uses_base() {
assert_eq!(crate::graduated_nice_value(50.0, 100, 3, 0), 3);
assert_eq!(crate::graduated_nice_value(100.0, 500, 7, 0), 7);
}
#[test]
fn graduated_nice_value_preserves_current_nice() {
assert_eq!(crate::graduated_nice_value(180.0, 0, 5, 10), 10);
assert_eq!(crate::graduated_nice_value(50.0, 100, 5, 10), 10);
}
#[test]
fn graduated_nice_value_negative_base_clamped_to_zero() {
assert_eq!(crate::graduated_nice_value(0.0, 0, -5, 0), 0);
}
#[test]
fn graduated_nice_value_high_base_clamped_to_max() {
assert_eq!(crate::graduated_nice_value(0.0, 0, 20, 0), 19);
}
#[test]
fn parse_ps_output_extracts_all_fields() {
let output = "12345 1 50.0 1024 3 cargo\n 23456 12345 25.0 2048 7 rustc";
let samples = crate::parse_ps_output(output);
assert_eq!(samples.len(), 2);
assert_eq!(samples[0].pid, 12345);
assert_eq!(samples[0].ppid, 1);
assert_eq!(samples[0].cpu_percent, 50.0);
assert_eq!(samples[0].rss_mb, 1024 / 1024); assert_eq!(samples[0].nice, 3);
assert_eq!(samples[0].command, "cargo");
assert_eq!(samples[1].pid, 23456);
assert_eq!(samples[1].ppid, 12345);
assert_eq!(samples[1].cpu_percent, 25.0);
assert_eq!(samples[1].rss_mb, 2048 / 1024); assert_eq!(samples[1].nice, 7);
assert_eq!(samples[1].command, "rustc");
}
#[test]
fn parse_ps_output_empty_input() {
let samples = crate::parse_ps_output("");
assert!(samples.is_empty());
}
#[test]
fn parse_ps_output_malformed_lines_skipped() {
let output = "not_valid\n12345 1 75.0 512 0 cargo";
let samples = crate::parse_ps_output(output);
assert_eq!(samples.len(), 1);
assert_eq!(samples[0].pid, 12345);
assert_eq!(samples[0].cpu_percent, 75.0);
}
#[test]
fn nice_restore_privilege_requires_root_or_cap_sys_nice() {
let unprivileged = "Uid:\t1000\t1000\t1000\t1000\nCapEff:\t0000000000000000\n";
let privileged = "Uid:\t1000\t1000\t1000\t1000\nCapEff:\t0000000000800000\n";
let root = "Uid:\t0\t0\t0\t0\nCapEff:\t0000000000000000\n";
assert!(!crate::has_nice_restore_privilege_from_status(unprivileged));
assert!(crate::has_nice_restore_privilege_from_status(privileged));
assert!(crate::has_nice_restore_privilege_from_status(root));
}
#[test]
fn disk_state_ok_below_warn() {
let guard = GuardPolicy::default();
assert_eq!(crate::disk_state(50, &guard), "ok");
assert_eq!(crate::disk_state(79, &guard), "ok");
}
#[test]
fn disk_state_warn_between_warn_and_action() {
let guard = GuardPolicy::default();
assert_eq!(crate::disk_state(80, &guard), "warn");
assert_eq!(crate::disk_state(89, &guard), "warn");
}
#[test]
fn disk_state_action_between_action_and_critical() {
let guard = GuardPolicy::default();
assert_eq!(crate::disk_state(90, &guard), "action");
assert_eq!(crate::disk_state(94, &guard), "action");
}
#[test]
fn disk_state_critical_at_or_above_critical() {
let guard = GuardPolicy::default();
assert_eq!(crate::disk_state(95, &guard), "critical");
assert_eq!(crate::disk_state(100, &guard), "critical");
}
#[test]
fn parse_df_use_percent_works() {
let output = "Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/sda1 19512345 15678901 3823444 80% /";
assert_eq!(crate::parse_df_use_percent(output), Some(80));
}
#[test]
fn parse_df_use_percent_parses_without_percent_sign() {
let output = "Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/sda1 19512345 15678901 3823444 80 /";
assert_eq!(crate::parse_df_use_percent(output), Some(80));
}
#[test]
fn parse_df_use_percent_no_matching_line() {
assert_eq!(crate::parse_df_use_percent(""), None);
assert_eq!(crate::parse_df_use_percent("no df output here"), None);
}
#[test]
fn should_notify_first_time_allowed() {
let mut state = crate::GuardRuntimeState::default();
let result = crate::should_notify(&mut state, "test-key", 300);
assert!(result);
}
#[test]
fn should_notify_respects_cooldown() {
let mut state = crate::GuardRuntimeState::default();
let first = crate::should_notify(&mut state, "my-event", 300);
assert!(first);
let second = crate::should_notify(&mut state, "my-event", 300);
assert!(!second);
state.notify_cooldowns.remove("my-event");
let third = crate::should_notify(&mut state, "my-event", 300);
assert!(third);
}
#[test]
fn report_state_transition_suppresses_unchanged_events() {
let mut state = crate::GuardRuntimeState::default();
let (previous, first) = crate::report_state_transition(&mut state, "memory", "warn", 1800);
assert!(previous.is_none());
assert!(first);
let (previous, repeated) = crate::report_state_transition(&mut state, "memory", "warn", 1800);
assert_eq!(previous.as_deref(), Some("warn"));
assert!(!repeated, "unchanged state must not emit every guard cycle");
let (previous, recovery) = crate::report_state_transition(&mut state, "memory", "ok", 1800);
assert_eq!(previous.as_deref(), Some("warn"));
assert!(recovery, "state transitions should emit immediately");
}
#[test]
fn memory_pressure_ignores_swap_occupancy_without_active_pressure() {
assert_eq!(
crate::classify_memory_pressure(false, true, false),
"ok",
"cold pages in swap are not active pressure"
);
assert_eq!(crate::classify_memory_pressure(true, false, false), "warn");
assert_eq!(
crate::classify_memory_pressure(true, true, false),
"critical"
);
assert_eq!(crate::classify_memory_pressure(false, false, true), "warn");
}
#[test]
fn swapin_velocity_flags_thrash_at_or_above_threshold() {
assert!(!crate::swapin_velocity_thrash(None, 1000));
assert!(!crate::swapin_velocity_thrash(Some(0.0), 1000));
assert!(!crate::swapin_velocity_thrash(Some(999.9), 1000));
assert!(crate::swapin_velocity_thrash(Some(1000.0), 1000));
assert!(crate::swapin_velocity_thrash(Some(170_000.0), 1000));
}
#[test]
fn swapin_velocity_threshold_defaults_to_the_legacy_fallback() {
assert_eq!(
crate::GuardPolicy::default().mem_swapin_warn_pages_per_sec,
1000
);
}
#[test]
fn memory_pressure_requires_persistence_before_transition() {
let mut state = crate::GuardRuntimeState::default();
let start = Instant::now();
let (stable, previous, changed) =
crate::stabilize_memory_pressure_at(&mut state, "warn", 120, start);
assert_eq!(stable, "ok");
assert!(previous.is_none());
assert!(!changed);
let (stable, _, changed) = crate::stabilize_memory_pressure_at(
&mut state,
"warn",
120,
start + Duration::from_secs(119),
);
assert_eq!(stable, "ok");
assert!(!changed);
let (stable, previous, changed) = crate::stabilize_memory_pressure_at(
&mut state,
"warn",
120,
start + Duration::from_secs(120),
);
assert_eq!(stable, "warn");
assert_eq!(previous.as_deref(), Some("ok"));
assert!(changed);
let (stable, _, changed) = crate::stabilize_memory_pressure_at(
&mut state,
"ok",
120,
start + Duration::from_secs(121),
);
assert_eq!(stable, "warn");
assert!(!changed);
let (stable, previous, changed) = crate::stabilize_memory_pressure_at(
&mut state,
"ok",
120,
start + Duration::from_secs(241),
);
assert_eq!(stable, "ok");
assert_eq!(previous.as_deref(), Some("warn"));
assert!(changed);
}
#[test]
fn predict_fill_time_requires_minimum_samples() {
let history: Vec<(Instant, u8)> = vec![];
assert!(crate::predict_fill_time(&history).is_none());
let history = vec![(Instant::now(), 70)];
assert!(crate::predict_fill_time(&history).is_none());
}
#[test]
fn predict_fill_time_returns_none_for_stable_disk() {
let now = Instant::now();
let history: Vec<(Instant, u8)> = vec![
(now - std::time::Duration::from_secs(3600), 50u8),
(now - std::time::Duration::from_secs(1800), 50u8),
(now, 50u8),
];
assert!(crate::predict_fill_time(&history).is_none());
}
#[test]
fn predict_fill_time_estimates_for_filling_disk() {
let now = Instant::now();
let history: Vec<(Instant, u8)> = vec![
(now - std::time::Duration::from_secs(7200), 30u8), (now - std::time::Duration::from_secs(3600), 60u8), (now, 90u8), ];
let result = crate::predict_fill_time(&history);
assert!(result.is_some());
let hours = result.unwrap();
assert!(hours > 0.0 && hours < 5.0); }
#[test]
fn auto_cleanup_result_default() {
let result = crate::AutoCleanupResult::default();
assert_eq!(result.cleaned_count, 0);
assert_eq!(result.reclaimed_bytes, 0);
assert!(result.cleaned_paths.is_empty());
assert!(result.protected_paths.is_empty());
}
#[test]
fn parse_meminfo_reads_all_four_fields() {
let sample = r#"MemTotal: 32754688 kB
MemFree: 1098776 kB
MemAvailable: 9010908 kB
SwapTotal: 32680964 kB
SwapFree: 11556016 kB
"#;
let m = crate::parse_meminfo(sample).expect("meminfo should parse");
assert_eq!(m.mem_total_kb, 32754688);
assert_eq!(m.mem_available_kb, 9010908);
assert_eq!(m.swap_total_kb, 32680964);
assert_eq!(m.swap_free_kb, 11556016);
assert_eq!(m.mem_available_percent(), 27);
assert_eq!(m.swap_used_percent(), 64);
}
#[test]
fn parse_meminfo_missing_swap_reports_zero_used() {
let sample = "MemTotal: 1048576 kB\nMemAvailable: 65536 kB\n";
let m = crate::parse_meminfo(sample).expect("meminfo should parse");
assert_eq!(m.mem_available_percent(), 6);
assert_eq!(m.swap_used_percent(), 0);
}
#[test]
fn parse_meminfo_garbage_returns_none() {
assert!(crate::parse_meminfo("not meminfo at all").is_none());
assert!(crate::parse_meminfo("").is_none());
}
#[test]
fn parse_pressure_memory_extracts_full_and_some() {
let psi = r#"some avg10=1.23 avg60=2.00 avg300=0.66 total=3743158654
full avg10=4.56 avg60=0.30 avg300=0.65 total=3579061268
"#;
let (full, some) = crate::parse_pressure_memory(psi).expect("psi should parse");
assert!((full - 4.56).abs() < 1e-9);
assert!((some - 1.23).abs() < 1e-9);
}
#[test]
fn parse_pressure_memory_no_full_line_returns_none() {
assert!(
crate::parse_pressure_memory("some avg10=1.00 avg60=1.00 avg300=1.00 total=1").is_none()
);
}
#[test]
fn parse_vmstat_swap_reads_counters() {
let vmstat = "pswpin 1022648496\npswpout 1187700919\nnr_free_pages 12345\n";
let (pin, pout) = crate::parse_vmstat_swap(vmstat).expect("vmstat should parse");
assert_eq!(pin, 1022648496);
assert_eq!(pout, 1187700919);
}
#[test]
fn record_swap_counters_preserves_pswpout() {
let mut state = crate::GuardRuntimeState::default();
crate::record_swap_counters(&mut state, 123, 456);
let (_, pswpin, pswpout) = state.prev_swap_counters.expect("counters recorded");
assert_eq!(pswpin, 123);
assert_eq!(pswpout, 456);
}
#[test]
fn parse_proc_stat_zombie_detects_z_state() {
let line = "1234 (chrome --type=renderer) Z 567 1 567 0 -1 4194304 42 0 0 0 0 0 0 0 20 0 1 0 999 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0";
let (pid, comm, ppid, starttime) =
crate::parse_proc_stat_zombie(line).expect("zombie stat should parse");
assert_eq!(pid, 1234);
assert_eq!(comm, "chrome --type=renderer");
assert_eq!(ppid, 567);
assert_eq!(starttime, 999);
}
#[test]
fn parse_proc_stat_zombie_ignores_non_zombies() {
let line = "42 (bash) S 1 42 42 0 -1 4194304 100 0 0 0 0 0 0 0 20 0 1 0 123 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0";
assert!(crate::parse_proc_stat_zombie(line).is_none());
}
#[test]
fn looks_credential_like_matches_known_patterns() {
assert!(crate::looks_credential_like("Login Data"));
assert!(crate::looks_credential_like("CREDENTIALS.md"));
assert!(crate::looks_credential_like("secrets.rs"));
assert!(crate::looks_credential_like(".env"));
assert!(crate::looks_credential_like("github.env"));
assert!(crate::looks_credential_like("id_ed25519.key"));
assert!(crate::looks_credential_like("backup.age"));
assert!(crate::looks_credential_like(".git-credentials"));
assert!(crate::looks_credential_like(".npmrc"));
assert!(crate::looks_credential_like("hosts.yml"));
assert!(crate::looks_credential_like("password-store"));
assert!(crate::looks_credential_like("wallet-token-cache"));
}
#[test]
fn looks_credential_like_ignores_benign_names() {
assert!(!crate::looks_credential_like("target"));
assert!(!crate::looks_credential_like("node_modules"));
assert!(!crate::looks_credential_like("ai-vid-editor"));
assert!(!crate::looks_credential_like("nixpkgs"));
assert!(!crate::looks_credential_like("go"));
assert!(!crate::looks_credential_like("saved_stuff"));
assert!(!crate::looks_credential_like("references"));
}
#[test]
fn disk_fill_rate_gbph_computes_sustained_rate() {
let now = Instant::now();
let history: Vec<(Instant, u64)> = vec![
(now - Duration::from_secs(7200), 500u64 * 1024 * 1024 * 1024),
(now - Duration::from_secs(3600), 510u64 * 1024 * 1024 * 1024),
(now, 520u64 * 1024 * 1024 * 1024),
];
let rate = crate::disk_fill_rate_gbph(&history).expect("rate should compute");
assert!((rate - 10.0).abs() < 0.5, "expected ~10 GiB/h, got {rate}");
}
#[test]
fn disk_fill_rate_gbph_requires_minimum_samples_and_span() {
let now = Instant::now();
assert!(crate::disk_fill_rate_gbph(&[]).is_none());
assert!(crate::disk_fill_rate_gbph(&[(now, 100), (now, 200)]).is_none());
let short: Vec<(Instant, u64)> = vec![
(now - Duration::from_secs(10), 100),
(now - Duration::from_secs(5), 200),
(now, 300),
];
assert!(crate::disk_fill_rate_gbph(&short).is_none());
}
#[test]
fn disk_fill_rate_gbph_returns_none_when_disk_shrinks() {
let now = Instant::now();
let history: Vec<(Instant, u64)> = vec![
(now - Duration::from_secs(7200), 600u64 * 1024 * 1024 * 1024),
(now - Duration::from_secs(3600), 550u64 * 1024 * 1024 * 1024),
(now, 500u64 * 1024 * 1024 * 1024),
];
assert!(crate::disk_fill_rate_gbph(&history).is_none());
}
#[test]
fn oom_bias_target_raises_neutral_values_to_250() {
assert_eq!(crate::oom_bias_target(0), Some(250));
assert_eq!(crate::oom_bias_target(100), Some(250));
assert_eq!(crate::oom_bias_target(-100), Some(250));
assert_eq!(crate::oom_bias_target(-300), Some(250));
}
#[test]
fn oom_bias_target_never_raises_already_biased_or_protected() {
assert_eq!(crate::oom_bias_target(250), None);
assert_eq!(crate::oom_bias_target(500), None);
assert_eq!(crate::oom_bias_target(1000), None);
assert_eq!(crate::oom_bias_target(-500), None);
assert_eq!(crate::oom_bias_target(-800), None);
assert_eq!(crate::oom_bias_target(-1000), None);
}
#[test]
fn oom_bias_target_boundaries() {
assert_eq!(crate::oom_bias_target(249), Some(250));
assert_eq!(crate::oom_bias_target(250), None);
assert_eq!(crate::oom_bias_target(-499), Some(250));
assert_eq!(crate::oom_bias_target(-500), None);
}
#[test]
fn memory_limiter_policy_defaults_are_safe() {
let p = crate::GuardPolicy::default();
assert!(!p.freeze_sync_at_action, "sync freeze must be opt-in");
assert!(!p.auto_renice, "CPU priority changes must be opt-in");
assert!(p.auto_renice_on_memory, "renice-on-memory default on");
assert!(p.bias_oom_on_pressure, "oom-bias default on");
assert_eq!(p.memory_pressure_sustain_secs, 120);
assert_eq!(p.report_repeat_secs, 1800);
assert_eq!(
p.cap_offenders_cpu_percent, 0,
"CPUQuota offender caps default OFF (opt-in)"
);
}
#[test]
fn normalize_guard_policy_clamps_cpu_cap_percent() {
let mut policy = crate::GuardPolicy {
cap_offenders_cpu_percent: u32::MAX,
..Default::default()
};
crate::normalize_guard_policy(&mut policy);
assert_eq!(policy.cap_offenders_cpu_percent, 100);
policy.cap_offenders_cpu_percent = 100;
crate::normalize_guard_policy(&mut policy);
assert_eq!(policy.cap_offenders_cpu_percent, 100);
}
#[test]
fn resolve_bin_falls_back_to_bare_name_off_nixos() {
assert_eq!(
crate::resolve_bin("dracon-definitely-not-a-binary"),
"dracon-definitely-not-a-binary"
);
assert_eq!(
crate::resolve_bin("dracon-definitely-not-a-binary"),
"dracon-definitely-not-a-binary"
);
}
#[test]
fn resolve_bin_strict_refuses_unresolvable_names() {
let err = crate::resolve_bin_strict("dracon-definitely-not-a-binary").unwrap_err();
assert!(
format!("{err:#}").contains("refusing PATH-relative exec"),
"strict resolve must refuse, not fall back: {err:#}"
);
}
#[test]
fn reap_policy_from_guard_maps_thresholds_and_shares_exempts() {
let guard = crate::GuardPolicy {
reap_report_min_idle_hours: 48,
reap_report_max_cpu_seconds: 120,
reap_report_signatures: "vite,mytool".to_string(),
process_exempt_names: "keepme,alsokeep".to_string(),
..Default::default()
};
let policy = crate::reap_policy_from_guard(&guard);
assert_eq!(policy.min_idle_hours, 48);
assert_eq!(policy.max_cpu_seconds, 120);
assert_eq!(
policy.signatures,
vec!["mytool".to_string(), "vite".to_string()]
);
assert!(policy.exempt_names.contains(&"keepme".to_string()));
assert!(policy.exempt_names.contains(&"alsokeep".to_string()));
}
#[test]
fn reap_candidates_runs_against_live_proc_without_panicking() {
let guard = crate::GuardPolicy::default();
let found = crate::reap_candidates(&guard);
for c in &found {
assert!(c.idle_hours >= guard.reap_report_min_idle_hours);
assert!(c.cpu_seconds <= guard.reap_report_max_cpu_seconds);
assert!(!c.args.is_empty());
}
}
#[test]
fn zombie_since_keys_carry_starttime_and_prune() {
let mut state = crate::GuardRuntimeState::default();
state
.zombies_since
.insert((999_998, 12345), std::time::Instant::now());
let _ = crate::zombie_details(&mut state);
assert!(
!state.zombies_since.contains_key(&(999_998, 12345)),
"stale zombie key for a vanished pid must be pruned"
);
}
#[test]
fn unavailable_identity_defer_drops_at_retry_limit() {
let mut attempts = std::collections::HashMap::new();
assert!(!crate::record_unavailable_attempt(&mut attempts, 4242));
assert!(!crate::record_unavailable_attempt(&mut attempts, 4242));
assert!(crate::record_unavailable_attempt(&mut attempts, 4242));
assert_eq!(attempts.get(&4242), Some(&3));
assert!(!crate::record_unavailable_attempt(&mut attempts, 4243));
}
#[test]
fn clean_node_modules_flag_defaults_true_and_gates_toml_off() {
let policy: crate::GuardPolicy = toml::from_str("").expect("empty policy parses with defaults");
assert!(policy.clean_node_modules);
let policy: crate::GuardPolicy =
toml::from_str("clean_node_modules = false\n").expect("flag-off parses");
assert!(!policy.clean_node_modules);
}
#[test]
fn policy_load_roundtrip_memory_limiter_knobs() {
let policy: crate::GuardPolicy = toml::from_str("").expect("empty policy parses with defaults");
assert!(policy.auto_renice_on_memory);
assert!(policy.bias_oom_on_pressure);
assert_eq!(policy.cap_offenders_cpu_percent, 0);
let policy: crate::GuardPolicy = toml::from_str(
"auto_renice_on_memory = false\nbias_oom_on_pressure = false\ncap_offenders_cpu_percent = 50\n",
)
.expect("explicit knobs parse");
assert!(!policy.auto_renice_on_memory);
assert!(!policy.bias_oom_on_pressure);
assert_eq!(policy.cap_offenders_cpu_percent, 50);
}
fn runtime_with_pending_nice(pid: i32) -> GuardRuntimeState {
let mut state = GuardRuntimeState::default();
state.memory_reniced_pids.insert(
pid,
MemoryReniceState {
original_nice: 3,
applied_nice: 10,
identity: ProcessIdentity {
comm: "worker".to_string(),
starttime: 42,
},
},
);
state
}
#[test]
fn a_fully_restored_reload_applies_the_new_policy_and_clears_runtime() {
let mut guard = GuardPolicy::default();
let mut runtime = runtime_with_pending_nice(4242);
let new_policy = GuardPolicy {
disk_warn_percent: 55,
..Default::default()
};
let outcome = apply_policy_reload(&mut guard, &mut runtime, new_policy, true);
assert_eq!(outcome, ReloadOutcome::Applied);
assert_eq!(guard.disk_warn_percent, 55, "new policy must be live");
assert!(
runtime.memory_reniced_pids.is_empty(),
"a clean restore must reset the runtime so no old state survives"
);
}
#[test]
fn a_partial_restore_keeps_the_previous_policy_live() {
let mut guard = GuardPolicy {
disk_warn_percent: 80,
..Default::default()
};
let mut runtime = runtime_with_pending_nice(4242);
let new_policy = GuardPolicy {
disk_warn_percent: 55,
..Default::default()
};
let outcome = apply_policy_reload(&mut guard, &mut runtime, new_policy, false);
assert_eq!(outcome, ReloadOutcome::Deferred);
assert_eq!(
guard.disk_warn_percent, 80,
"a deferred reload must not adopt the new policy"
);
assert_eq!(
runtime.memory_reniced_pids.len(),
1,
"the pending adjustment must be retained so the still-armed mitigation retries it"
);
}
#[test]
fn a_partial_restore_cannot_strand_an_adjustment_the_new_policy_disables() {
let mut guard = GuardPolicy {
auto_renice_on_memory: true,
..Default::default()
};
let mut runtime = runtime_with_pending_nice(4242);
let new_policy = GuardPolicy {
auto_renice_on_memory: false,
..Default::default()
};
let outcome = apply_policy_reload(&mut guard, &mut runtime, new_policy, false);
assert_eq!(outcome, ReloadOutcome::Deferred);
assert!(
guard.auto_renice_on_memory,
"the mitigation that produced the outstanding adjustment must stay armed, \
otherwise the process is reniced forever with no path to restore it"
);
assert!(
runtime.memory_reniced_pids.contains_key(&4242),
"the outstanding adjustment must survive so it is retried"
);
}
#[test]
fn a_partial_restore_with_only_threshold_changes_is_also_deferred() {
let mut guard = GuardPolicy::default();
let mut runtime = runtime_with_pending_nice(7);
let new_policy = GuardPolicy {
interval_secs: 5,
..Default::default()
};
assert_eq!(
apply_policy_reload(&mut guard, &mut runtime, new_policy, false),
ReloadOutcome::Deferred
);
assert_ne!(
guard.interval_secs, 5,
"the new interval must not be adopted"
);
}
#[test]
fn disabling_the_guard_is_applied_even_after_a_partial_restore() {
let mut guard = GuardPolicy::default();
let mut runtime = runtime_with_pending_nice(99);
let new_policy = GuardPolicy {
enabled: false,
..Default::default()
};
let outcome = apply_policy_reload(&mut guard, &mut runtime, new_policy, false);
assert_eq!(outcome, ReloadOutcome::Applied);
assert!(!guard.enabled, "enabled = false must always take effect");
assert_eq!(
runtime.memory_reniced_pids.len(),
1,
"state is retained for the shutdown restore, not dropped"
);
}
#[test]
fn reload_is_idempotent_across_repeated_sighups() {
let mut guard = GuardPolicy::default();
let mut runtime = GuardRuntimeState::default();
for _ in 0..5 {
let p = GuardPolicy {
disk_warn_percent: 70,
..Default::default()
};
assert_eq!(
apply_policy_reload(&mut guard, &mut runtime, p, true),
ReloadOutcome::Applied
);
}
assert_eq!(guard.disk_warn_percent, 70);
assert!(runtime.memory_reniced_pids.is_empty());
}
#[test]
fn a_reload_that_succeeds_after_a_deferred_one_recovers() {
let mut guard = GuardPolicy::default();
let mut runtime = runtime_with_pending_nice(1);
let p1 = GuardPolicy {
disk_warn_percent: 55,
..Default::default()
};
assert_eq!(
apply_policy_reload(&mut guard, &mut runtime, p1, false),
ReloadOutcome::Deferred
);
let p2 = GuardPolicy {
disk_warn_percent: 55,
..Default::default()
};
assert_eq!(
apply_policy_reload(&mut guard, &mut runtime, p2, true),
ReloadOutcome::Applied
);
assert_eq!(guard.disk_warn_percent, 55);
assert!(runtime.memory_reniced_pids.is_empty());
}
fn gated_release_markers(src: &str) -> Result<Vec<String>, String> {
let start = src
.find("if pressure == \"ok\" {")
.ok_or_else(|| "the pressure-release block was not found".to_string())?;
let gate = src[start..]
.find("if can_restore_nice {")
.map(|i| start + i)
.ok_or_else(|| {
"the renice half is no longer gated on the restore capability".to_string()
})?;
let body = &src[gate..];
let mut depth = 0i32;
let mut end = None;
for (i, ch) in body.char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = Some(gate + i);
break;
}
}
_ => {}
}
}
let end = end.ok_or_else(|| "the capability gate is not brace-balanced".to_string())?;
let gated = &src[gate..end];
let mut wrongly_gated = Vec::new();
for marker in [
"Restore oom_score_adj after the release window",
"Lift CPUQuota scopes after the release window",
] {
if gated.contains(marker) {
wrongly_gated.push(marker.to_string());
}
if !src[start..].contains(marker) {
return Err(format!(
"'{marker}' no longer exists: the release was deleted, not just ungated"
));
}
}
if !gated.contains("mem-unrenice") {
return Err(
"the renice release left the can_restore_nice gate, but its apply is still gated"
.to_string(),
);
}
Ok(wrongly_gated)
}
#[test]
fn oom_and_cpu_releases_are_not_gated_by_the_nice_capability() {
let wrongly = gated_release_markers(include_str!("main.rs"))
.expect("the release block must be shaped as the fix requires");
assert!(
wrongly.is_empty(),
"these releases are inside the can_restore_nice gate again, so a host without \
CAP_SYS_NICE can never release them: {wrongly:?}"
);
}
#[test]
fn the_structural_guard_catches_a_reintroduced_defect() {
let pre_fix = r#"
if pressure == "ok" {
if can_restore_nice {
eprintln!("mem-unrenice");
}
// Restore oom_score_adj after the release window.
let x = 1;
// Lift CPUQuota scopes after the release window.
let y = 2;
} else {
}
"#;
let pre_fix_nested = pre_fix.replace(
" if can_restore_nice {\n eprintln!(\"mem-unrenice\");\n }\n",
" if can_restore_nice {\n eprintln!(\"mem-unrenice\");\n // Restore oom_score_adj after the release window.\n let x = 1;\n // Lift CPUQuota scopes after the release window.\n let y = 2;\n }\n",
);
let wrongly = gated_release_markers(&pre_fix_nested)
.expect("the synthetic snippet must still be parseable");
assert_eq!(
wrongly.len(),
2,
"the guard must flag both releases when they are nested in the gate: {wrongly:?}"
);
}
#[test]
fn pending_oom_descendant_retries_are_bounded() {
let mut pending = OomPendingDescendant {
root_pid: 1,
original_adj: 0,
identity: ProcessIdentity {
starttime: 2,
comm: "child".to_string(),
},
attempts: 0,
};
for attempt in 1..OOM_DESCENDANT_RETRY_LIMIT {
assert!(
!oom_descendant_attempts_exhausted(&mut pending),
"attempt {attempt} must still be retried"
);
assert_eq!(pending.attempts, attempt);
}
assert!(
oom_descendant_attempts_exhausted(&mut pending),
"attempt {} must give up so the root is not pinned forever",
OOM_DESCENDANT_RETRY_LIMIT
);
assert_eq!(pending.attempts, OOM_DESCENDANT_RETRY_LIMIT);
}
#[test]
fn remembered_oom_descendants_are_pruned_past_the_cap() {
use std::collections::HashSet;
let mut state = GuardRuntimeState::default();
let identity = ProcessIdentity {
starttime: 1,
comm: "root".to_string(),
};
state.oom_biased_pids.insert(100, (0, identity.clone()));
let live = (999, 42u64);
let mut known: HashSet<(i32, u64)> = HashSet::new();
known.insert(live);
for i in 0..OOM_KNOWN_DESCENDANTS_CAP {
known.insert((1000 + i as i32, u64::MAX - i as u64));
}
assert!(
known.len() > OOM_KNOWN_DESCENDANTS_CAP,
"the fixture must exceed the cap to exercise the prune"
);
state.oom_known_descendants.insert(100, known);
state
.oom_known_descendants
.insert(200, HashSet::from([(1, 1)]));
let all_processes = vec![crate::ProcSample {
pid: live.0,
ppid: 1,
cpu_percent: 0.0,
rss_mb: 0,
nice: 0,
command: "child".to_string(),
args: String::new(),
starttime: live.1,
}];
prune_oom_known_descendants(&mut state, &all_processes);
assert!(
!state.oom_known_descendants.contains_key(&200),
"a root that is no longer biased must lose its bookkeeping"
);
let kept = &state.oom_known_descendants[&100];
assert!(
kept.len() <= OOM_KNOWN_DESCENDANTS_CAP,
"the set must not stay above the cap, got {}",
kept.len()
);
assert!(
kept.contains(&live),
"a descendant that is still alive must be remembered"
);
}
#[test]
fn the_mitigation_ledger_survives_a_restart() {
let dir = std::env::temp_dir().join(format!(
"dracon-system-mitigation-ledger-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("guard-mitigations.json");
let me = std::process::id() as i32;
let live_identity = {
let stat = std::fs::read_to_string(format!("/proc/{me}/stat")).unwrap_or_default();
let after = stat.rsplit_once(") ").map(|(_, rest)| rest.to_string());
let starttime = after
.as_deref()
.and_then(|rest| rest.split_whitespace().nth(19))
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(0);
ProcessIdentity {
starttime,
comm: "self".to_string(),
}
};
let mut before = GuardRuntimeState::default();
before.memory_reniced_pids.insert(
me,
MemoryReniceState {
original_nice: 0,
applied_nice: 5,
identity: live_identity.clone(),
},
);
before
.oom_biased_pids
.insert(me, (0, live_identity.clone()));
persist_mitigations_to(&mut before, &path);
assert!(path.exists(), "the ledger must be written");
let first = std::fs::metadata(&path).unwrap().modified().unwrap();
persist_mitigations_to(&mut before, &path);
let second = std::fs::metadata(&path).unwrap().modified().unwrap();
assert_eq!(
first, second,
"an unchanged pass must not rewrite the ledger"
);
let mut after_restart = GuardRuntimeState::default();
hydrate_mitigations_from(&path, &mut after_restart);
assert!(
after_restart.memory_reniced_pids.contains_key(&me),
"a renice left applied by the previous run must be adopted"
);
assert!(
after_restart.oom_biased_pids.contains_key(&me),
"an oom bias left applied by the previous run must be adopted"
);
let mut recycled = GuardRuntimeState::default();
let mut bad = std::fs::read_to_string(&path).unwrap();
bad = bad.replace(
&format!("\"starttime\":{}", live_identity.starttime),
"\"starttime\":999999999",
);
std::fs::write(&path, bad).unwrap();
hydrate_mitigations_from(&path, &mut recycled);
assert!(
recycled.memory_reniced_pids.is_empty() && recycled.oom_biased_pids.is_empty(),
"a ledger entry whose identity does not match must be ignored, not adopted"
);
std::fs::write(&path, "{ not json").unwrap();
let mut corrupt = GuardRuntimeState::default();
hydrate_mitigations_from(&path, &mut corrupt);
assert!(
corrupt.memory_reniced_pids.is_empty(),
"a corrupt ledger must not block the daemon"
);
let _ = std::fs::remove_dir_all(dir);
}
fn cap_cgroup_fixture(label: &str) -> (std::path::PathBuf, i32, ProcessIdentity) {
let dir = std::env::temp_dir().join(format!(
"dracon-system-{label}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::fs::create_dir_all(&dir).expect("scratch dir");
let me = std::process::id() as i32;
let stat = std::fs::read_to_string(format!("/proc/{me}/stat")).unwrap_or_default();
let starttime = stat
.rsplit_once(") ")
.and_then(|(_, rest)| rest.split_whitespace().nth(19))
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(0);
let identity = ProcessIdentity {
starttime,
comm: "self".to_string(),
};
(dir.join("guard-mitigations.json"), me, identity)
}
#[test]
fn mitigation_ledger_round_trips_the_capped_pids_original_cgroup() {
let (path, me, identity) = cap_cgroup_fixture("ledger-cap-cgroup");
let orig_cgroup = "user.slice/user-1000.slice/user@1000.service/app.slice";
let mut before = GuardRuntimeState::default();
before.capped_pids.insert(
me,
(
"run-r1.service".to_string(),
orig_cgroup.to_string(),
identity.clone(),
),
);
persist_mitigations_to(&mut before, &path);
let mut after_restart = GuardRuntimeState::default();
hydrate_mitigations_from(&path, &mut after_restart);
let (scope, restored_cgroup, _) = after_restart
.capped_pids
.get(&me)
.expect("a cap left applied by the previous run must be adopted");
assert_eq!(scope, "run-r1.service");
assert_eq!(
restored_cgroup, orig_cgroup,
"the cgroup to restore to must survive the restart, not hydrate empty"
);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn a_legacy_ledger_without_the_cgroup_still_hydrates_and_never_guesses_a_path() {
let (path, me, identity) = cap_cgroup_fixture("ledger-cap-legacy");
let legacy = serde_json::json!({
"records": [{
"pid": me,
"original_nice": serde_json::Value::Null,
"original_oom_adj": serde_json::Value::Null,
"cap_scope": "run-r9.service",
"identity": {"starttime": identity.starttime, "comm": "self"},
}]
})
.to_string();
assert!(
!legacy.contains("cap_orig_cgroup"),
"the legacy fixture must not contain the newer field"
);
std::fs::write(&path, legacy).unwrap();
let mut after_restart = GuardRuntimeState::default();
hydrate_mitigations_from(&path, &mut after_restart);
let (_, restored_cgroup, _) = after_restart
.capped_pids
.get(&me)
.expect("a legacy ledger entry with a matching identity must still be adopted");
assert_eq!(
restored_cgroup, "",
"a legacy ledger has no cgroup, and must be adopted with it empty"
);
let current = "user.slice/user-1000.slice/user@1000.service/run-r9.service";
assert_eq!(
restore_cgroup_target("", current).as_deref(),
Some("user.slice/user-1000.slice/user@1000.service"),
"with no recorded cgroup the pid goes back to the scope unit's parent"
);
assert_eq!(
restore_cgroup_target("user.slice/app.slice", current).as_deref(),
Some("user.slice/app.slice")
);
assert_eq!(restore_cgroup_target("", "run-r9.service"), None);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}