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.
149/// `subject` names whatever is being waited on for the two terminal log
150/// lines ("relaunching {subject}" / "giving up {subject} relaunch") — this
151/// function is shared by the host-OOM wait (`subject: "host"`) and the
152/// srv-OOM wait (`subject: "srv"`, `SPEC_MEMORY_PRESSURE_SUPERVISION`'s srv
153/// counterpart). Hardcoding "host" here made the srv-OOM wait's log
154/// misleadingly claim it was relaunching the host during an actual srv-OOM
155/// incident — reagent P1 on PR #2206.
156pub async fn await_commit_recovery(subject: &str, log: impl Fn(&str)) -> bool {
157 let start = Instant::now();
158 let mut backoff = BACKOFF_START;
159 loop {
160 let free = commit_free_mb();
161 if free >= RESUME_FLOOR_MB {
162 log(&format!(
163 "commit recovered: {} MB free (>= {} MB floor) — relaunching {}",
164 free, RESUME_FLOOR_MB, subject
165 ));
166 return true;
167 }
168 if start.elapsed() >= OOM_RELAUNCH_DEADLINE {
169 log(&format!(
170 "commit still low ({} MB free) after {}s — giving up {} relaunch",
171 free,
172 OOM_RELAUNCH_DEADLINE.as_secs(),
173 subject
174 ));
175 return false;
176 }
177 log(&format!(
178 "system out of commit ({} MB free, need {} MB) — waiting {}s before re-check",
179 free,
180 RESUME_FLOOR_MB,
181 backoff.as_secs()
182 ));
183 tokio::time::sleep(backoff).await;
184 backoff = next_backoff(backoff);
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn oom_exit_code_roundtrips_to_known_signed_value() {
194 // Documents the i32 value the launcher will actually compare against.
195 assert_eq!(CHROMIUM_OOM_EXIT_CODE, -536_870_904);
196 }
197
198 #[test]
199 fn exact_oom_code_is_system_oom_even_with_headroom() {
200 // The exact Chromium OOM code is OOM regardless of the commit reading,
201 // which may already have recovered by the time we sample it.
202 assert_eq!(
203 classify_host_exit(CHROMIUM_OOM_EXIT_CODE, 8192),
204 HostExitClass::SystemOom
205 );
206 }
207
208 #[test]
209 fn abnormal_code_with_low_commit_is_system_oom() {
210 // OOM misreported as a generic crash code — the low commit reading still
211 // catches it (the OS was out of memory).
212 assert_eq!(
213 classify_host_exit(1, RESUME_FLOOR_MB - 1),
214 HostExitClass::SystemOom
215 );
216 }
217
218 #[test]
219 fn abnormal_code_with_headroom_is_a_host_bug() {
220 // A genuine crash with memory available → existing fast wedged-host path.
221 assert_eq!(classify_host_exit(1, RESUME_FLOOR_MB), HostExitClass::Abnormal);
222 assert_eq!(classify_host_exit(-1, 16_384), HostExitClass::Abnormal);
223 }
224
225 #[test]
226 fn srv_fastfail_code_with_low_commit_is_system_oom() {
227 // docs/retro/retro-subagent-backfill-storm-oom-2026-07-17.md: srv is a
228 // plain Rust process, so an OOM there never emits
229 // CHROMIUM_OOM_EXIT_CODE — it surfaces as Windows' generic fail-fast
230 // abort, 0xC0000409 (STATUS_STACK_BUFFER_OVERRUN — the code name is
231 // misleading; the CRT overloads it for abort()/allocation-failure
232 // fast-fails too, not just real buffer overruns). The exact live exit
233 // code from the incident, reused here as a regression anchor:
234 // classify_host_exit is generic over which process died — only the
235 // low-commit fallback branch needs to catch it, not an exact-code
236 // match — so this is a straight application of the existing
237 // low-commit branch, now exercised by the launcher's srv-exit arm too.
238 const SRV_FASTFAIL_EXIT_CODE: i32 = -1_073_740_791;
239 assert_eq!(
240 classify_host_exit(SRV_FASTFAIL_EXIT_CODE, RESUME_FLOOR_MB - 1),
241 HostExitClass::SystemOom
242 );
243 // Same code with headroom present is just a genuine srv crash.
244 assert_eq!(
245 classify_host_exit(SRV_FASTFAIL_EXIT_CODE, RESUME_FLOOR_MB),
246 HostExitClass::Abnormal
247 );
248 }
249
250 #[test]
251 fn backoff_doubles_then_caps() {
252 assert_eq!(next_backoff(Duration::from_secs(2)), Duration::from_secs(4));
253 assert_eq!(next_backoff(Duration::from_secs(4)), Duration::from_secs(8));
254 // 16 → 32 capped to 30.
255 assert_eq!(next_backoff(Duration::from_secs(16)), Duration::from_secs(30));
256 // Stays at the cap.
257 assert_eq!(next_backoff(Duration::from_secs(30)), Duration::from_secs(30));
258 }
259
260 #[test]
261 fn budget_allows_up_to_n_then_exhausts() {
262 let now = Instant::now();
263 let window = Duration::from_secs(60);
264 let mut r = Vec::new();
265 assert!(!budget_exhausted(&mut r, now, window, 3));
266 assert!(!budget_exhausted(&mut r, now, window, 3));
267 assert!(!budget_exhausted(&mut r, now, window, 3));
268 // Fourth within the window → exhausted, and it is NOT recorded.
269 assert!(budget_exhausted(&mut r, now, window, 3));
270 assert_eq!(r.len(), 3);
271 }
272
273 #[test]
274 fn budget_forgets_restarts_outside_the_window() {
275 let base = Instant::now();
276 let window = Duration::from_secs(60);
277 let mut r = Vec::new();
278 for _ in 0..3 {
279 let _ = budget_exhausted(&mut r, base, window, 3);
280 }
281 assert!(budget_exhausted(&mut r, base, window, 3)); // exhausted at base
282 // A restart well past the window prunes the stale entries → room again.
283 let later = base + Duration::from_secs(120);
284 assert!(!budget_exhausted(&mut r, later, window, 3));
285 assert_eq!(r.len(), 1);
286 }
287}