agentmux_launcher/mem_supervisor.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Memory-pressure-aware host supervision — P0 of
5//! `docs/specs/SPEC_MEMORY_PRESSURE_SUPERVISION_2026_06_16.md`.
6//!
7//! The launcher's host-relaunch ladder (`HOST_RESTART_BUDGET`) is otherwise
8//! memory-blind: on a *system out-of-memory* host crash it relaunches straight
9//! back into the same commit-starved condition, burns the budget in seconds, and
10//! gives up — a silent vanish (see `docs/retro/retro-oom-crash-2026-06-16.md`).
11//!
12//! This module adds the discrimination: a system-OOM exit is waited out (commit-
13//! gated, backed-off relaunch on a *separate*, longer OOM budget), while a
14//! genuine host fault still trips the fast wedged-host budget + degraded ladder
15//! unchanged. The decision logic here is pure and unit-tested; the platform
16//! commit probe and the backoff wait are thin wrappers over the OS.
17
18use std::time::{Duration, Instant};
19
20/// Chromium's intentional out-of-memory abort code (`base::win::kOomExceptionCode`,
21/// raised non-continuably via `KERNELBASE!RaiseException` when an allocation
22/// fails). It surfaces as the host's exit code; `ExitStatus::code()` returns it
23/// as an `i32`, so the unsigned `0xE000_0008` reads back as `-536_870_904`.
24pub const CHROMIUM_OOM_EXIT_CODE: i32 = 0xE000_0008u32 as i32;
25
26/// Minimum commit-free (available page file) headroom, in MB, before it is safe
27/// to (re)launch a CEF host without it instantly re-OOMing. A fresh host +
28/// renderer commits on the order of a few hundred MB; 512 leaves margin. Shared
29/// starting point with the renderer spec's `RESUME_FLOOR` (SPEC §6).
30pub const RESUME_FLOOR_MB: u64 = 512;
31
32/// Restart budget for *system-OOM* host exits — larger and longer than the
33/// wedged-host `HOST_RESTART_BUDGET` (3 / 60 s), because transient system
34/// pressure is the OS's problem, not a host bug, and recovery can legitimately
35/// take minutes (SPEC §5.B / §6).
36pub const OOM_RESTART_BUDGET: usize = 5;
37pub const OOM_RESTART_WINDOW: Duration = Duration::from_secs(600);
38
39/// Commit-gated relaunch backoff: while commit-free is below `RESUME_FLOOR_MB`,
40/// wait this long and re-probe, doubling each time up to the cap. Relaunching
41/// into a starved system just re-OOMs, so waiting is the only thing that works.
42pub const BACKOFF_START: Duration = Duration::from_secs(2);
43pub const BACKOFF_CAP: Duration = Duration::from_secs(30);
44
45/// Hard ceiling on how long to wait for commit to recover before giving up (and
46/// showing the honest "out of memory" dialog rather than waiting forever).
47pub const OOM_RELAUNCH_DEADLINE: Duration = Duration::from_secs(300);
48
49/// User-facing copy for the graceful give-up dialog (SPEC §5.C). Shown via the
50/// launcher's `show_fatal_dialog` (a renderer-free Win32 `MessageBoxW`) so the
51/// crash is never a silent vanish.
52pub const OOM_GIVEUP_TITLE: &str = "AgentMux — out of memory";
53pub const OOM_GIVEUP_BODY: &str = "AgentMux ran low on system memory and couldn't recover this window.\n\n\
54Your panes, agents, and sign-ins are saved. Close some other apps or AgentMux windows to free memory, then reopen AgentMux to restore your session.";
55
56/// Classification of an abnormal (non-zero, non-clean-shutdown) host exit.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum HostExitClass {
59 /// The OS was out of commit — transient and unavoidable. Wait it out on the
60 /// separate OOM budget; do NOT consume the wedged-host budget.
61 SystemOom,
62 /// A genuine host fault (bug, GPU-process cascade, …). Use the existing fast
63 /// wedged-host budget + degraded ladder, unchanged.
64 Abnormal,
65}
66
67/// Classify an abnormal host exit. OOM is identified two ways, deliberately
68/// defensive — Chromium sometimes surfaces an OOM as a generic crash code rather
69/// than the exact OOM exception (electron#40426):
70/// 1. the exact Chromium OOM exit code, OR
71/// 2. *any* abnormal exit taken while commit-free was already below the resume
72/// floor — the OS was out of memory, so whatever died, died of it.
73pub fn classify_host_exit(exit_code: i32, commit_free_mb: u64) -> HostExitClass {
74 if exit_code == CHROMIUM_OOM_EXIT_CODE || commit_free_mb < RESUME_FLOOR_MB {
75 HostExitClass::SystemOom
76 } else {
77 HostExitClass::Abnormal
78 }
79}
80
81/// Next backoff duration: double `prev`, capped at `BACKOFF_CAP`.
82pub fn next_backoff(prev: Duration) -> Duration {
83 (prev * 2).min(BACKOFF_CAP)
84}
85
86/// Crash-budget bookkeeping, factored out so it is unit-testable. Retains only
87/// restarts within `window` of `now`; if the surviving count is already at
88/// `budget`, returns `true` (exhausted — caller gives up) WITHOUT recording.
89/// Otherwise records `now` and returns `false`. Matches the existing inline
90/// wedged-host budget semantics (check-before-push).
91pub fn budget_exhausted(
92 restarts: &mut Vec<Instant>,
93 now: Instant,
94 window: Duration,
95 budget: usize,
96) -> bool {
97 restarts.retain(|t| now.duration_since(*t) < window);
98 if restarts.len() >= budget {
99 return true;
100 }
101 restarts.push(now);
102 false
103}
104
105/// Available commit (page file) in MB. The OS commit pool is process-global, so
106/// the launcher reads it directly — no shared memory with the host's heartbeat
107/// is needed. Returns `u64::MAX` if the probe fails, so a probe failure can never
108/// *gate* a relaunch (fail open).
109#[cfg(target_os = "windows")]
110pub fn commit_free_mb() -> u64 {
111 use windows_sys::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
112 let mut s: MEMORYSTATUSEX = unsafe { std::mem::zeroed() };
113 s.dwLength = std::mem::size_of::<MEMORYSTATUSEX>() as u32;
114 // SAFETY: `s` is a correctly-sized, zeroed MEMORYSTATUSEX with dwLength set.
115 if unsafe { GlobalMemoryStatusEx(&mut s) } == 0 {
116 return u64::MAX;
117 }
118 s.ullAvailPageFile / (1024 * 1024)
119}
120
121/// Linux commit proxy: `MemAvailable` from `/proc/meminfo`. The kernel OOM-killer
122/// (a SIGKILL) is the real OOM signal here; the abnormal-exit-plus-low-commit
123/// reading together approximate it (SPEC §9.4 open question).
124#[cfg(target_os = "linux")]
125pub fn commit_free_mb() -> u64 {
126 if let Ok(s) = std::fs::read_to_string("/proc/meminfo") {
127 for line in s.lines() {
128 if let Some(rest) = line.strip_prefix("MemAvailable:") {
129 if let Some(kb) = rest.split_whitespace().next().and_then(|n| n.parse::<u64>().ok()) {
130 return kb / 1024;
131 }
132 }
133 }
134 }
135 u64::MAX
136}
137
138/// macOS has no cheap commit figure; fail open until a platform probe lands.
139#[cfg(not(any(target_os = "windows", target_os = "linux")))]
140pub fn commit_free_mb() -> u64 {
141 u64::MAX
142}
143
144/// Wait for system commit to recover above `RESUME_FLOOR_MB`, probing with
145/// exponential backoff (`BACKOFF_START` → `BACKOFF_CAP`). Returns `true` once
146/// recovered, or `false` if `OOM_RELAUNCH_DEADLINE` elapses first (the caller
147/// then gives up gracefully). `log` is the launcher's logger, threaded in so the
148/// wait is observable in the launcher log.
149pub async fn await_commit_recovery(log: impl Fn(&str)) -> bool {
150 let start = Instant::now();
151 let mut backoff = BACKOFF_START;
152 loop {
153 let free = commit_free_mb();
154 if free >= RESUME_FLOOR_MB {
155 log(&format!(
156 "commit recovered: {} MB free (>= {} MB floor) — relaunching host",
157 free, RESUME_FLOOR_MB
158 ));
159 return true;
160 }
161 if start.elapsed() >= OOM_RELAUNCH_DEADLINE {
162 log(&format!(
163 "commit still low ({} MB free) after {}s — giving up host relaunch",
164 free,
165 OOM_RELAUNCH_DEADLINE.as_secs()
166 ));
167 return false;
168 }
169 log(&format!(
170 "system out of commit ({} MB free, need {} MB) — waiting {}s before re-check",
171 free,
172 RESUME_FLOOR_MB,
173 backoff.as_secs()
174 ));
175 tokio::time::sleep(backoff).await;
176 backoff = next_backoff(backoff);
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn oom_exit_code_roundtrips_to_known_signed_value() {
186 // Documents the i32 value the launcher will actually compare against.
187 assert_eq!(CHROMIUM_OOM_EXIT_CODE, -536_870_904);
188 }
189
190 #[test]
191 fn exact_oom_code_is_system_oom_even_with_headroom() {
192 // The exact Chromium OOM code is OOM regardless of the commit reading,
193 // which may already have recovered by the time we sample it.
194 assert_eq!(
195 classify_host_exit(CHROMIUM_OOM_EXIT_CODE, 8192),
196 HostExitClass::SystemOom
197 );
198 }
199
200 #[test]
201 fn abnormal_code_with_low_commit_is_system_oom() {
202 // OOM misreported as a generic crash code — the low commit reading still
203 // catches it (the OS was out of memory).
204 assert_eq!(
205 classify_host_exit(1, RESUME_FLOOR_MB - 1),
206 HostExitClass::SystemOom
207 );
208 }
209
210 #[test]
211 fn abnormal_code_with_headroom_is_a_host_bug() {
212 // A genuine crash with memory available → existing fast wedged-host path.
213 assert_eq!(classify_host_exit(1, RESUME_FLOOR_MB), HostExitClass::Abnormal);
214 assert_eq!(classify_host_exit(-1, 16_384), HostExitClass::Abnormal);
215 }
216
217 #[test]
218 fn backoff_doubles_then_caps() {
219 assert_eq!(next_backoff(Duration::from_secs(2)), Duration::from_secs(4));
220 assert_eq!(next_backoff(Duration::from_secs(4)), Duration::from_secs(8));
221 // 16 → 32 capped to 30.
222 assert_eq!(next_backoff(Duration::from_secs(16)), Duration::from_secs(30));
223 // Stays at the cap.
224 assert_eq!(next_backoff(Duration::from_secs(30)), Duration::from_secs(30));
225 }
226
227 #[test]
228 fn budget_allows_up_to_n_then_exhausts() {
229 let now = Instant::now();
230 let window = Duration::from_secs(60);
231 let mut r = Vec::new();
232 assert!(!budget_exhausted(&mut r, now, window, 3));
233 assert!(!budget_exhausted(&mut r, now, window, 3));
234 assert!(!budget_exhausted(&mut r, now, window, 3));
235 // Fourth within the window → exhausted, and it is NOT recorded.
236 assert!(budget_exhausted(&mut r, now, window, 3));
237 assert_eq!(r.len(), 3);
238 }
239
240 #[test]
241 fn budget_forgets_restarts_outside_the_window() {
242 let base = Instant::now();
243 let window = Duration::from_secs(60);
244 let mut r = Vec::new();
245 for _ in 0..3 {
246 let _ = budget_exhausted(&mut r, base, window, 3);
247 }
248 assert!(budget_exhausted(&mut r, base, window, 3)); // exhausted at base
249 // A restart well past the window prunes the stale entries → room again.
250 let later = base + Duration::from_secs(120);
251 assert!(!budget_exhausted(&mut r, later, window, 3));
252 assert_eq!(r.len(), 1);
253 }
254}