agentmux_launcher/
host_hang_dump.rs1use std::path::{Path, PathBuf};
18
19pub const HOST_HANG_DUMP_MISSES: u32 = 2;
24
25pub const MAX_DUMPS_KEPT: usize = 5;
27
28#[derive(Default)]
31pub struct HangDumpLatch {
32 dumped_this_episode: bool,
33}
34
35impl HangDumpLatch {
36 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
50pub 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
60pub 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
70pub fn dump_file_name(unix_secs: u64, pid: u32) -> String {
72 format!("agentmux-host-hang-{unix_secs:012}-pid{pid}.dmp")
73}
74
75pub 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#[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 let ok = unsafe {
120 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 #[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 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}