Skip to main content

agentmux_launcher/
host_hang_dump.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! SPEC_HOST_UI_THREAD_HANG_WATCHDOG_2026_08_14 §1–2 — capture evidence of a
5//! wedged host UI thread before anything (a person, usually) kills it.
6//!
7//! On 2026-08-14 the host's UI thread stopped pumping for ~16 minutes with a
8//! window open; it was killed by hand and nothing had captured a stack, so the
9//! deadlock's cause is still unknown. A deadlock raises no exception, so the
10//! crash handler path can't see it — the dump has to be taken EXTERNALLY by
11//! another process (what `procdump -h` does), here the launcher, which already
12//! supervises the host and probes its UI thread (`ui_liveness`).
13//!
14//! Detection + dump only. Automatically killing and respawning a wedged host
15//! (spec §1's last step) is a separate decision and not done here.
16
17use std::path::{Path, PathBuf};
18
19/// Dump once the UI thread has missed this many consecutive liveness probes.
20/// The probe runs every 60 s (`supervisor/windows.rs`), so this is a UI thread
21/// silent for at least ~1–2 minutes. Two, not one: a laptop sleep can cost at
22/// most one miss (the first probe after resume is answered).
23pub const HOST_HANG_DUMP_MISSES: u32 = 2;
24
25/// Keep at most this many hang dumps; older ones are deleted.
26pub const MAX_DUMPS_KEPT: usize = 5;
27
28/// One dump per hang episode. Feed it `ui_liveness::consecutive_misses()`
29/// after each probe tick; a reply resets the misses to 0, which re-arms it.
30#[derive(Default)]
31pub struct HangDumpLatch {
32    dumped_this_episode: bool,
33}
34
35impl HangDumpLatch {
36    /// True exactly once per episode, when the misses first reach the threshold.
37    pub fn observe(&mut self, consecutive_misses: u32) -> bool {
38        if consecutive_misses == 0 {
39            self.dumped_this_episode = false;
40            return false;
41        }
42        if consecutive_misses >= HOST_HANG_DUMP_MISSES && !self.dumped_this_episode {
43            self.dumped_this_episode = true;
44            return true;
45        }
46        false
47    }
48}
49
50/// `%LOCALAPPDATA%\CrashDumps\agentmux-host-hang\<instance_key>` — kept
51/// apart from crash dumps (a hang dump and a crash dump mean different things
52/// to triage), and one folder per instance (`instance_key` = the launcher's
53/// data-dir hash) so pruning never deletes another running instance's
54/// evidence (ReAgent P2 on #3913).
55pub fn dump_dir(instance_key: &str) -> Option<PathBuf> {
56    let base = std::env::var_os("LOCALAPPDATA")?;
57    dump_dir_under(Path::new(&base), instance_key)
58}
59
60/// `dump_dir` with an explicit base. `None` for a key that isn't a single
61/// plain path component — it is joined onto a path.
62pub fn dump_dir_under(base: &Path, instance_key: &str) -> Option<PathBuf> {
63    let plain = !instance_key.is_empty()
64        && instance_key
65            .chars()
66            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
67    plain.then(|| base.join("CrashDumps").join("agentmux-host-hang").join(instance_key))
68}
69
70/// Sortable by name: zero-padded timestamp first.
71pub fn dump_file_name(unix_secs: u64, pid: u32) -> String {
72    format!("agentmux-host-hang-{unix_secs:012}-pid{pid}.dmp")
73}
74
75/// Delete all but the newest `keep` hang dumps in `dir`. Best-effort.
76pub fn prune_old_dumps(dir: &Path, keep: usize) {
77    let Ok(entries) = std::fs::read_dir(dir) else {
78        return;
79    };
80    let mut dumps: Vec<PathBuf> = entries
81        .filter_map(|e| e.ok().map(|e| e.path()))
82        .filter(|p| {
83            p.file_name()
84                .and_then(|n| n.to_str())
85                .is_some_and(|n| n.starts_with("agentmux-host-hang-") && n.ends_with(".dmp"))
86        })
87        .collect();
88    dumps.sort();
89    let excess = dumps.len().saturating_sub(keep);
90    for old in dumps.into_iter().take(excess) {
91        let _ = std::fs::remove_file(old);
92    }
93}
94
95/// Write a minidump of process `pid` into `dir` from OUTSIDE the process —
96/// works while every thread of the target is blocked. Thread stacks + thread
97/// info + handles (for lock/wait analysis) + module lists; not full memory,
98/// which keeps it to a few MB and limits what user data it can contain.
99#[cfg(target_os = "windows")]
100pub fn write_hang_dump(pid: u32, dir: &Path) -> Result<PathBuf, String> {
101    use std::os::windows::io::AsRawHandle;
102    use windows_sys::Win32::Foundation::CloseHandle;
103    use windows_sys::Win32::System::Diagnostics::Debug::{
104        MiniDumpWithHandleData, MiniDumpWithThreadInfo, MiniDumpWithUnloadedModules,
105        MiniDumpWriteDump,
106    };
107    use windows_sys::Win32::System::Threading::{
108        OpenProcess, PROCESS_DUP_HANDLE, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ,
109    };
110
111    std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
112    let secs = agentmux_common::time::now_secs_u64();
113    let path = dir.join(dump_file_name(secs, pid));
114    let file = std::fs::File::create(&path).map_err(|e| format!("create {}: {e}", path.display()))?;
115
116    // SAFETY: OpenProcess/MiniDumpWriteDump/CloseHandle with a handle we own
117    // and close on every path; the file handle stays valid for the call
118    // because `file` outlives it.
119    let ok = unsafe {
120        // DUP_HANDLE: MiniDumpWithHandleData silently omits the handle
121        // stream without it — and handles are how a deadlock dump shows
122        // which mutex/event each thread is waiting on.
123        let process = OpenProcess(
124            PROCESS_QUERY_INFORMATION | PROCESS_VM_READ | PROCESS_DUP_HANDLE,
125            0,
126            pid,
127        );
128        if process.is_null() {
129            drop(file);
130            let _ = std::fs::remove_file(&path);
131            return Err(format!("OpenProcess({pid}) failed: {}", std::io::Error::last_os_error()));
132        }
133        let dump_type = MiniDumpWithThreadInfo | MiniDumpWithHandleData | MiniDumpWithUnloadedModules;
134        let ok = MiniDumpWriteDump(
135            process,
136            pid,
137            file.as_raw_handle() as _,
138            dump_type,
139            std::ptr::null(),
140            std::ptr::null(),
141            std::ptr::null(),
142        );
143        let err = std::io::Error::last_os_error();
144        CloseHandle(process);
145        if ok == 0 {
146            Err(err)
147        } else {
148            Ok(())
149        }
150    };
151    drop(file);
152    match ok {
153        Ok(()) => {
154            prune_old_dumps(dir, MAX_DUMPS_KEPT);
155            Ok(path)
156        }
157        Err(e) => {
158            let _ = std::fs::remove_file(&path);
159            Err(format!("MiniDumpWriteDump({pid}) failed: {e}"))
160        }
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn latch_fires_once_per_episode_at_the_threshold() {
170        let mut l = HangDumpLatch::default();
171        assert!(!l.observe(1), "one miss (e.g. a sleep/resume) is not a hang");
172        assert!(l.observe(2), "second consecutive miss → dump");
173        assert!(!l.observe(3), "still the same episode — no second dump");
174        assert!(!l.observe(9));
175    }
176
177    #[test]
178    fn latch_rearms_after_the_ui_thread_answers() {
179        let mut l = HangDumpLatch::default();
180        assert!(l.observe(2));
181        assert!(!l.observe(0), "a reply resets misses to 0");
182        assert!(!l.observe(1));
183        assert!(l.observe(2), "a new hang gets its own dump");
184    }
185
186    #[test]
187    fn each_instance_gets_its_own_dump_folder() {
188        let base = Path::new("base");
189        let a = dump_dir_under(base, "0123456789abcdef").unwrap();
190        let b = dump_dir_under(base, "fedcba9876543210").unwrap();
191        assert_ne!(a, b, "pruning in one must not touch the other");
192        assert_eq!(
193            a,
194            base.join("CrashDumps").join("agentmux-host-hang").join("0123456789abcdef")
195        );
196    }
197
198    #[test]
199    fn a_key_that_is_not_a_plain_component_is_rejected() {
200        let base = Path::new("base");
201        assert!(dump_dir_under(base, "").is_none());
202        assert!(dump_dir_under(base, "..").is_none());
203        assert!(dump_dir_under(base, "a/b").is_none());
204        assert!(dump_dir_under(base, "a\\b").is_none());
205    }
206
207    #[test]
208    fn file_names_sort_chronologically() {
209        let a = dump_file_name(999, 42);
210        let b = dump_file_name(1_000, 7);
211        assert!(a < b, "{a} should sort before {b}");
212        assert!(a.starts_with("agentmux-host-hang-") && a.ends_with(".dmp"));
213    }
214
215    #[test]
216    fn prune_keeps_only_the_newest_dumps_and_ignores_other_files() {
217        let dir = std::env::temp_dir().join(format!("amux-hang-prune-{}", std::process::id()));
218        let _ = std::fs::remove_dir_all(&dir);
219        std::fs::create_dir_all(&dir).unwrap();
220        for t in 1..=7u64 {
221            std::fs::write(dir.join(dump_file_name(t, 1)), b"x").unwrap();
222        }
223        std::fs::write(dir.join("notes.txt"), b"keep me").unwrap();
224
225        prune_old_dumps(&dir, 3);
226
227        let mut left: Vec<String> = std::fs::read_dir(&dir)
228            .unwrap()
229            .map(|e| e.unwrap().file_name().into_string().unwrap())
230            .collect();
231        left.sort();
232        assert_eq!(
233            left,
234            vec![dump_file_name(5, 1), dump_file_name(6, 1), dump_file_name(7, 1), "notes.txt".into()]
235        );
236        let _ = std::fs::remove_dir_all(&dir);
237    }
238
239    /// Real MiniDumpWriteDump against a live child process.
240    #[cfg(target_os = "windows")]
241    #[test]
242    fn writes_a_readable_dump_of_another_process() {
243        let mut child = std::process::Command::new("cmd")
244            .args(["/c", "ping -n 30 127.0.0.1 >NUL"])
245            .spawn()
246            .expect("spawn child");
247        let dir = std::env::temp_dir().join(format!("amux-hang-dump-{}", std::process::id()));
248        let _ = std::fs::remove_dir_all(&dir);
249
250        let result = write_hang_dump(child.id(), &dir);
251        let _ = child.kill();
252        let _ = child.wait();
253
254        let path = result.expect("dump written");
255        let bytes = std::fs::read(&path).unwrap();
256        assert_eq!(&bytes[..4], b"MDMP", "minidump signature");
257        // Stream directory: header u32 NumberOfStreams @8, u32 StreamDirectoryRva @12;
258        // each entry is (u32 StreamType, u32 DataSize, u32 Rva).
259        let u32_at = |o: usize| u32::from_le_bytes(bytes[o..o + 4].try_into().unwrap());
260        let (n, stream_dir) = (u32_at(8) as usize, u32_at(12) as usize);
261        let types: Vec<u32> = (0..n).map(|i| u32_at(stream_dir + 12 * i)).collect();
262        const THREAD_LIST: u32 = 3;
263        const HANDLE_DATA: u32 = 12;
264        assert!(types.contains(&THREAD_LIST), "thread stacks: {types:?}");
265        assert!(types.contains(&HANDLE_DATA), "handle data: {types:?}");
266        let _ = std::fs::remove_dir_all(&dir);
267    }
268}