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}