latexml_core/watchdog.rs
1//! Wall-clock watchdog that forcibly aborts the process after a deadline.
2//!
3//! The existing `stomach::check_timeout()` is a cooperative mechanism — it only
4//! fires when the digestion loop polls it. That leaves tight native loops
5//! (Marpa precompute / parse, libxml2 post-processing, FFI calls into libxslt,
6//! ...) completely unguarded: a 60-second timeout can easily turn into 10
7//! minutes if control never returns to the digestion loop.
8//!
9//! This module provides a main-level `Watchdog` that spawns a dedicated thread
10//! at construction, wakes after the specified number of seconds, and — if the
11//! watchdog has not yet been cancelled — prints a message and calls
12//! `std::process::abort()`. That guarantees the process dies within
13//! `timeout + poll_interval` of the configured deadline, regardless of what
14//! the main thread is doing.
15//!
16//! # Design notes
17//!
18//! - Uses `Arc<AtomicBool>` for cancellation. Polling every 100 ms keeps the cancellation latency
19//! low without burning CPU.
20//! - `Drop` on the `Watchdog` handle cancels the watchdog thread, so RAII usage (`let _wd =
21//! Watchdog::new(secs)`) is sufficient.
22//! - We use `std::process::abort()` rather than `panic!` because panic may unwind or be caught by a
23//! surrounding `catch_unwind`, which would defeat the safety guarantee. `abort()` delivers
24//! `SIGABRT` and always terminates the process.
25//! - The existing cooperative `stomach::check_timeout()` path is retained: on most conversions it
26//! fires before the hard abort, giving callers a nice `Err(Fatal)` with proper error propagation.
27//! The watchdog is a safety net for the pathological cases where cooperative polling doesn't
28//! happen.
29//!
30//! # Resource limits
31//!
32//! [`Watchdog::with_limits`](crate::watchdog::Watchdog::with_limits) guards
33//! **both** a wall-clock deadline and a
34//! resident-memory ceiling — the two defenses any executable that converts
35//! arbitrary input needs. It is the shared guard reused by both
36//! `cortex_worker` (in-process, one paper per process) and the
37//! `latexml_oxide --server` LSP (run inside each forked body child, which
38//! self-terminates on breach so the parent reaps it via pipe EOF). The exit
39//! codes are distinct so a supervising parent can tell them apart:
40//! `124` = wall-clock timeout, `137` = memory ceiling.
41//!
42//! # Portability
43//!
44//! The wall-clock guard is portable (`std::thread` + `Instant`).
45//!
46//! **Portable:** [`total_memory_bytes`](crate::watchdog::total_memory_bytes) and [`available_disk_bytes`](crate::watchdog::available_disk_bytes) answer on
47//! Linux, macOS and Windows — `sysconf(_SC_PHYS_PAGES)`/`statvfs` are POSIX, and
48//! Windows uses `GlobalMemoryStatusEx`/`GetDiskFreeSpaceExW`. They back the
49//! machine-derived default ceiling ([`default_ceiling_mib`](crate::watchdog::default_ceiling_mib)) and the
50//! spill-headroom check, so those behave identically on every supported OS.
51//!
52//! **Enforcement, also portable:** [`process_rss_kb`](crate::watchdog::process_rss_kb) — the half that actually
53//! checks live usage against the ceiling — answers on all three: Linux samples
54//! `/proc/self/status`, macOS asks libproc (`proc_pidinfo`), and Windows uses
55//! `GetProcessMemoryInfo`. So the memory ceiling is both computed *and* checked
56//! on every supported OS; other targets fall back to `None` (time guard only).
57
58use std::{
59 sync::{
60 Arc,
61 atomic::{AtomicBool, Ordering},
62 },
63 thread,
64 time::{Duration, Instant},
65};
66
67/// Current resident set size of this process in KiB, or `None` if it can't be
68/// determined. Linux reads `VmRSS` from `/proc/self/status`; macOS asks
69/// libproc (`proc_pidinfo`/`PROC_PIDTASKINFO`) — without it the whole
70/// `--max-memory` ceiling silently did not exist on macOS, and the
71/// `115_streaming_cli` Fatal-contract guard caught exactly that on macOS CI
72/// (an over-budget run exited 0). Cheap enough to poll a few times a second.
73#[cfg(target_os = "linux")]
74pub fn process_rss_kb() -> Option<u64> {
75 let status = std::fs::read_to_string("/proc/self/status").ok()?;
76 for line in status.lines() {
77 if let Some(rest) = line.strip_prefix("VmRSS:") {
78 return rest.split_whitespace().next()?.parse::<u64>().ok();
79 }
80 }
81 None
82}
83
84/// macOS: libproc task info; `pti_resident_size` is in BYTES.
85#[cfg(target_os = "macos")]
86pub fn process_rss_kb() -> Option<u64> {
87 let mut info: libc::proc_taskinfo = unsafe { std::mem::zeroed() };
88 let size = std::mem::size_of::<libc::proc_taskinfo>() as libc::c_int;
89 // SAFETY: proc_pidinfo fills at most `size` bytes of the zeroed struct we
90 // own; a short or negative return is handled below.
91 let got = unsafe {
92 libc::proc_pidinfo(
93 std::process::id() as libc::c_int,
94 libc::PROC_PIDTASKINFO,
95 0,
96 (&raw mut info).cast::<libc::c_void>(),
97 size,
98 )
99 };
100 (got == size).then(|| info.pti_resident_size / 1024)
101}
102
103/// Windows: `GetProcessMemoryInfo`'s `WorkingSetSize` is this process's resident
104/// set — the RSS analog the cooperative ceiling needs. Without it the whole
105/// `--max-memory` ceiling silently did not exist on Windows (exactly as it did
106/// not on macOS before the libproc arm), and the `115_streaming_cli`
107/// Fatal-contract guard caught it: an over-budget run exited 0. `windows-sys` is
108/// already linked, and the `K32`-prefixed forwarder lives in kernel32, so this
109/// adds no new DLL import to the self-contained release binary.
110#[cfg(windows)]
111pub fn process_rss_kb() -> Option<u64> {
112 use windows_sys::Win32::System::{
113 ProcessStatus::{K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS},
114 Threading::GetCurrentProcess,
115 };
116 let mut counters: PROCESS_MEMORY_COUNTERS = unsafe { std::mem::zeroed() };
117 counters.cb = size_of::<PROCESS_MEMORY_COUNTERS>() as u32;
118 // SAFETY: `counters` is a correctly sized, zeroed struct with `cb` set, as the
119 // API requires; `GetCurrentProcess` returns a pseudo-handle needing no close.
120 if unsafe { K32GetProcessMemoryInfo(GetCurrentProcess(), &mut counters, counters.cb) } != 0 {
121 return Some(counters.WorkingSetSize as u64 / 1024);
122 }
123 None
124}
125
126/// Other platforms: unknown — the cooperative memory ceiling stays inactive.
127#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
128pub fn process_rss_kb() -> Option<u64> { None }
129
130/// Kernel-tracked PEAK resident set of this process in KiB (`VmHWM`), or `None`
131/// if it can't be determined. Unlike [`process_rss_kb`]'s point-in-time sample,
132/// this is the true high-water mark over the whole process lifetime — no
133/// sampling cadence can miss a spike — which makes it the honest basis for the
134/// end-of-run "this document needed N" report ([`peak_memory_report`]).
135#[cfg(target_os = "linux")]
136pub fn process_peak_rss_kb() -> Option<u64> {
137 let status = std::fs::read_to_string("/proc/self/status").ok()?;
138 for line in status.lines() {
139 if let Some(rest) = line.strip_prefix("VmHWM:") {
140 return rest.split_whitespace().next()?.parse::<u64>().ok();
141 }
142 }
143 None
144}
145
146/// macOS: `getrusage(RUSAGE_SELF)`; `ru_maxrss` is in BYTES on Darwin.
147#[cfg(target_os = "macos")]
148pub fn process_peak_rss_kb() -> Option<u64> {
149 let mut usage: libc::rusage = unsafe { std::mem::zeroed() };
150 // SAFETY: getrusage fills the zeroed struct we own; a nonzero return is
151 // handled below.
152 if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut usage) } == 0 {
153 return Some(usage.ru_maxrss as u64 / 1024);
154 }
155 None
156}
157
158/// Windows: `PeakWorkingSetSize` from the same `GetProcessMemoryInfo` call that
159/// backs [`process_rss_kb`].
160#[cfg(windows)]
161pub fn process_peak_rss_kb() -> Option<u64> {
162 use windows_sys::Win32::System::{
163 ProcessStatus::{K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS},
164 Threading::GetCurrentProcess,
165 };
166 let mut counters: PROCESS_MEMORY_COUNTERS = unsafe { std::mem::zeroed() };
167 counters.cb = size_of::<PROCESS_MEMORY_COUNTERS>() as u32;
168 // SAFETY: `counters` is a correctly sized, zeroed struct with `cb` set, as the
169 // API requires; `GetCurrentProcess` returns a pseudo-handle needing no close.
170 if unsafe { K32GetProcessMemoryInfo(GetCurrentProcess(), &mut counters, counters.cb) } != 0 {
171 return Some(counters.PeakWorkingSetSize as u64 / 1024);
172 }
173 None
174}
175
176/// Other platforms: no peak available — the report line is simply omitted.
177#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
178pub fn process_peak_rss_kb() -> Option<u64> { None }
179
180/// Latched by the cooperative memory fuse when it raises its
181/// `Timeout:MemoryBudget` Fatal (`stomach::check_timeout`), read at end of run
182/// by [`peak_memory_report`]. An `AtomicBool` static, not a thread-local: the
183/// fuse fires on the conversion thread while the binary's end-of-run reporting
184/// runs on the main thread.
185static MEMORY_FATAL_SEEN: AtomicBool = AtomicBool::new(false);
186
187/// Record that a memory-budget Fatal fired, so the end-of-run report knows
188/// memory was actually the problem.
189pub fn note_memory_fatal() { MEMORY_FATAL_SEEN.store(true, Ordering::Relaxed); }
190
191/// The end-of-run memory report — emitted ONLY when a memory-budget Fatal
192/// fired during the run (user directive 2026-08-03: alert when needed, stay
193/// quiet on clean runs). `None` otherwise, or when no peak is measurable.
194///
195/// It reports the kernel-tracked peak and says the document needs MORE — never
196/// a specific sufficient figure, because none is knowable from a truncated
197/// run: the fuse clamped the peak at 75% of the ceiling, and the streaming
198/// spill watermark itself derives from the ceiling, so the true requirement
199/// can only be found by rerunning higher. A document's need scales with macro
200/// expansion and math density, not source bytes (the 131 MB math-dense
201/// witness needs ~23 GB resident just to stream through core), so this
202/// measured figure is the only honest lower bound there is.
203pub fn peak_memory_report() -> Option<String> {
204 if !MEMORY_FATAL_SEEN.load(Ordering::Relaxed) {
205 return None;
206 }
207 let peak_kb = process_peak_rss_kb()?;
208 Some(format!(
209 "peak memory {} MB was not enough: this document needs a higher --max-memory ceiling \
210 (0 disables the limit) and enough free RAM",
211 peak_kb / 1024,
212 ))
213}
214
215/// Total physical RAM on this machine in bytes, or `None` if it cannot be
216/// determined.
217///
218/// Portable by construction: `sysconf(_SC_PHYS_PAGES) * sysconf(_SC_PAGE_SIZE)`
219/// is POSIX and answers on both Linux and macOS, and Windows has
220/// `GlobalMemoryStatusEx`. No new dependency — `libc` and `windows-sys` are
221/// already in the tree.
222///
223/// Deliberately NOT `/proc/meminfo`: that would repeat the Linux-only mistake
224/// this module already carries in [`process_rss_kb`], which returns `None`
225/// everywhere else and so silently deactivates the memory ceiling on
226/// macOS/Windows.
227pub fn total_memory_bytes() -> Option<u64> {
228 #[cfg(unix)]
229 {
230 // SAFETY: `sysconf` is a pure query with no pointer arguments; a negative
231 // return means "unavailable", which we map to `None` rather than trusting.
232 let pages = unsafe { libc::sysconf(libc::_SC_PHYS_PAGES) };
233 let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
234 if pages > 0 && page_size > 0 {
235 return (pages as u64).checked_mul(page_size as u64);
236 }
237 None
238 }
239 #[cfg(windows)]
240 {
241 // `MEMORYSTATUSEX` is what windows-sys calls this, mirroring the Win32
242 // header. This arm originally said `GLOBAL_MEMORY_STATUS_EX` — a spelling
243 // from a different generation of the bindings — while Cargo.toml pinned
244 // `windows-sys = "0.61"`, so it never matched the version it declared and
245 // has NEVER compiled. It survived because CI builds Linux and macOS only:
246 // the first Windows compile of this file was the 0.7.5-rc4 RELEASE
247 // workflow, on a tag, days after the code landed. See task #164.
248 use windows_sys::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
249 let mut status: MEMORYSTATUSEX = unsafe { std::mem::zeroed() };
250 status.dwLength = size_of::<MEMORYSTATUSEX>() as u32;
251 // SAFETY: `status` is a correctly sized, zeroed struct with `dwLength` set,
252 // exactly as the API requires.
253 if unsafe { GlobalMemoryStatusEx(&mut status) } != 0 {
254 return Some(status.ullTotalPhys);
255 }
256 None
257 }
258 #[cfg(not(any(unix, windows)))]
259 {
260 None
261 }
262}
263
264/// The default per-conversion memory ceiling in MiB, derived from the machine
265/// **as it is right now**.
266///
267/// `min(64 GiB, 90 % of AVAILABLE RAM at startup)`, never above the
268/// cgroup-capped total, floored at [`MIN_DEFAULT_CEILING_MIB`]. When
269/// availability cannot be probed the rule falls back to half of the
270/// (cgroup-capped) total, and to [`FALLBACK_CEILING_MIB`] when nothing can be
271/// probed at all. One sentence for users: *a conversion may use most of the
272/// RAM that is actually free when it starts, never more than 64 GiB.*
273///
274/// History of the rule (each step measured, none guessed):
275/// - flat 6144 MiB — absurd on a 256 GB host, over-generous on a laptop;
276/// - 90 % of TOTAL RAM — laptop-hostile: blind to what the session already
277/// uses, it let one conversion push a busy 16 GB machine deep into swap;
278/// - half of TOTAL RAM — laptop-safe but wasteful the other way: on the 31 GB
279/// witness laptop it derives ~15.5 GiB while the machine sits idle with
280/// ~28 GB free, and the 131 MB witness (24.2 GB core peak) then dies at a
281/// fuse it did not need to meet (user directive 2026-08-01: "we can
282/// comfortably use up to 24 GB here");
283/// - 90 % of AVAILABLE — self-adjusting: on that idle laptop it derives
284/// ~25 GiB, and on the same laptop with a browser session holding 6 GB it
285/// derives roughly what the half-of-total rule chose. `MemAvailable` is the
286/// kernel's own estimate of what is reclaimable without swapping, so "the
287/// session starts swapping long before the guard fires" — the failure that
288/// killed the 90 %-of-total rule — cannot recur by construction.
289///
290/// The **64 GiB cap** is not about this machine but about the *others*: in a
291/// parallel fleet the aggregate is `N_processes x ceiling`, so an uncapped
292/// fraction-of-RAM rule on a big host would let a busy fleet OOM it. The
293/// `cortex_worker` fleet overrides this with its own per-child ceiling anyway;
294/// the cap keeps the single-process default from being reckless.
295///
296/// Startup-derived, deliberately: the figure is resolved once and becomes the
297/// run's `--max-memory`, so a neighbours' later allocations cannot move a
298/// running conversion's fuse mid-flight.
299pub fn default_ceiling_mib() -> u64 {
300 derive_ceiling_mib(available_memory_bytes(), effective_memory_bytes())
301}
302
303/// The pure arithmetic behind [`default_ceiling_mib`], split out so the rule
304/// is unit-testable without controlling the test host's actual memory state.
305fn derive_ceiling_mib(available: Option<u64>, effective_total: Option<u64>) -> u64 {
306 const MIB: u64 = 1024 * 1024;
307 let candidate = match (available, effective_total) {
308 // Headroom rule: 90 % of what is free right now, never above what the
309 // process may use at all (the cgroup-capped total).
310 (Some(avail), Some(total)) => (avail / MIB * 9 / 10).min(total / MIB),
311 (Some(avail), None) => avail / MIB * 9 / 10,
312 // Availability unknown (exotic platform): the conservative half-of-total
313 // rule this default previously shipped.
314 (None, Some(total)) => total / MIB / 2,
315 (None, None) => return FALLBACK_CEILING_MIB,
316 };
317 candidate.clamp(MIN_DEFAULT_CEILING_MIB, MAX_DEFAULT_CEILING_MIB)
318}
319
320/// RAM available for new allocations right now, in bytes, or `None` when the
321/// platform offers no honest answer.
322///
323/// "Available" here means the OS's own estimate of what a new consumer can
324/// take **without pushing the machine into swap** — not merely "free", which
325/// undercounts by excluding reclaimable caches:
326/// - Linux: `MemAvailable` from `/proc/meminfo`, the kernel's purpose-built
327/// estimate (free + reclaimable page cache − watermarks). This is the one
328/// legitimate use of `/proc/meminfo` in this module — availability has no
329/// `sysconf` spelling — and it degrades safely: `None` on any other Unix,
330/// which the ceiling rule answers with the half-of-total fallback.
331/// - Windows: `GlobalMemoryStatusEx`'s `ullAvailPhys`, the direct analog.
332/// - macOS: `host_statistics64` free + inactive pages — inactive is macOS's
333/// reclaimable class, the moral equivalent of Linux's page cache share.
334pub fn available_memory_bytes() -> Option<u64> {
335 #[cfg(target_os = "linux")]
336 {
337 let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
338 for line in meminfo.lines() {
339 if let Some(rest) = line.strip_prefix("MemAvailable:") {
340 let kb = rest.split_whitespace().next()?.parse::<u64>().ok()?;
341 return kb.checked_mul(1024);
342 }
343 }
344 None
345 }
346 #[cfg(target_os = "macos")]
347 {
348 let mut stats: libc::vm_statistics64 = unsafe { std::mem::zeroed() };
349 let mut count = (size_of::<libc::vm_statistics64>() / size_of::<libc::integer_t>())
350 as libc::mach_msg_type_number_t;
351 // SAFETY: `stats` is a correctly sized, zeroed struct and `count` names
352 // its capacity in `integer_t` units, exactly as the API requires;
353 // `mach_host_self` returns a send right that need not be deallocated for
354 // a one-shot query in a short-lived process.
355 let kr = unsafe {
356 libc::host_statistics64(
357 libc::mach_host_self(),
358 libc::HOST_VM_INFO64,
359 (&raw mut stats).cast::<libc::integer_t>(),
360 &mut count,
361 )
362 };
363 if kr != libc::KERN_SUCCESS {
364 return None;
365 }
366 let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
367 if page_size <= 0 {
368 return None;
369 }
370 (stats.free_count as u64 + stats.inactive_count as u64).checked_mul(page_size as u64)
371 }
372 #[cfg(windows)]
373 {
374 use windows_sys::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
375 let mut status: MEMORYSTATUSEX = unsafe { std::mem::zeroed() };
376 status.dwLength = size_of::<MEMORYSTATUSEX>() as u32;
377 // SAFETY: `status` is a correctly sized, zeroed struct with `dwLength`
378 // set, exactly as the API requires.
379 if unsafe { GlobalMemoryStatusEx(&mut status) } != 0 {
380 return Some(status.ullAvailPhys);
381 }
382 None
383 }
384 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
385 {
386 None
387 }
388}
389
390/// Memory this PROCESS may actually use: physical RAM, capped by the cgroup
391/// limit when one is in force.
392///
393/// `total_memory_bytes` reports the HOST's RAM (`sysconf(_SC_PHYS_PAGES)`),
394/// which is blind to containers — so `docker run -m 4g` on a 258 GB host chose
395/// a 64 GiB ceiling and a 48 GiB cooperative fuse, neither of which the process
396/// could ever reach. The kernel killed it at 4 GB instead: exit 137, no output,
397/// no `Fatal:` line — exactly the failure the graceful guard exists to replace.
398/// The cortex fleet is only accidentally safe here, because it pins every child
399/// with `--max-rss-mb`; a plain containerized CLI user is not.
400fn effective_memory_bytes() -> Option<u64> {
401 let physical = total_memory_bytes();
402 match (physical, cgroup_limit_bytes()) {
403 (Some(p), Some(c)) => Some(p.min(c)),
404 (p, None) => p,
405 (None, c) => c,
406 }
407}
408
409/// The cgroup memory limit, v2 then v1, or `None` when unlimited/unreadable.
410///
411/// Both spell "unlimited" in their own way: v2 writes the literal `max`, v1
412/// writes a huge sentinel (`u64::MAX` rounded down to a page multiple), so a
413/// value at or above the host's RAM is treated as no limit rather than as a
414/// bound worth honouring.
415fn cgroup_limit_bytes() -> Option<u64> {
416 let v2 = std::fs::read_to_string("/sys/fs/cgroup/memory.max").ok();
417 let v1 = || std::fs::read_to_string("/sys/fs/cgroup/memory/memory.limit_in_bytes").ok();
418 let raw = v2.or_else(v1)?;
419 interpret_cgroup_limit(&raw, total_memory_bytes())
420}
421
422/// Decide what a cgroup limit file MEANS. Split out from the file read so the
423/// rules are testable without a container: the reachable spellings of
424/// "unlimited" are what make this subtle.
425fn interpret_cgroup_limit(raw: &str, physical: Option<u64>) -> Option<u64> {
426 let text = raw.trim();
427 // cgroup v2 spells unlimited literally.
428 if text == "max" {
429 return None;
430 }
431 let limit = text.parse::<u64>().ok()?;
432 // cgroup v1 spells it as a huge sentinel, and a "limit" at or above the
433 // host's RAM bounds nothing — treat both as no limit rather than as a
434 // ceiling worth honouring.
435 if limit == 0 || physical.is_some_and(|phys| limit >= phys) {
436 return None;
437 }
438 Some(limit)
439}
440
441/// Floor for the machine-derived default, so a small container still gets a
442/// workable budget rather than a ceiling no conversion can fit under.
443pub const MIN_DEFAULT_CEILING_MIB: u64 = 2048;
444
445/// Upper bound on the machine-derived default ceiling (64 GiB in MiB).
446pub const MAX_DEFAULT_CEILING_MIB: u64 = 64 * 1024;
447
448/// Ceiling used when the machine's RAM cannot be probed — the historical flat
449/// default, kept so an unprobeable platform behaves exactly as before rather
450/// than losing its guard entirely.
451pub const FALLBACK_CEILING_MIB: u64 = 6144;
452
453/// Free space in bytes on the filesystem holding `path`, or `None` if it cannot
454/// be determined. Used to check headroom before spilling intermediates to disk.
455pub fn available_disk_bytes(path: &std::path::Path) -> Option<u64> {
456 #[cfg(unix)]
457 {
458 use std::os::unix::ffi::OsStrExt;
459 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
460 // SAFETY: zeroed `statvfs` is a valid initial state; `c_path` is a
461 // NUL-terminated string that outlives the call.
462 let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
463 if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 {
464 return None;
465 }
466 // `f_bavail` is blocks available to unprivileged users — the honest figure,
467 // as `f_bfree` includes the root-reserved slice we cannot use.
468 (stat.f_bavail as u64).checked_mul(stat.f_frsize as u64)
469 }
470 #[cfg(windows)]
471 {
472 use std::os::windows::ffi::OsStrExt;
473
474 use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW;
475 let mut wide: Vec<u16> = path.as_os_str().encode_wide().collect();
476 wide.push(0);
477 let mut free_to_caller: u64 = 0;
478 // SAFETY: `wide` is NUL-terminated and outlives the call; the two unused
479 // out-parameters are passed as null, which the API permits.
480 let ok = unsafe {
481 GetDiskFreeSpaceExW(
482 wide.as_ptr(),
483 &mut free_to_caller,
484 std::ptr::null_mut(),
485 std::ptr::null_mut(),
486 )
487 };
488 (ok != 0).then_some(free_to_caller)
489 }
490 #[cfg(not(any(unix, windows)))]
491 {
492 let _ = path;
493 None
494 }
495}
496
497/// Exit code used when the wall-clock deadline is exceeded (standard `timeout`).
498pub const EXIT_TIMEOUT: i32 = 124;
499/// Exit code used when the memory ceiling is exceeded (128 + SIGKILL).
500pub const EXIT_OOM: i32 = 137;
501
502/// Handle to a watchdog thread. Cancels on drop.
503///
504/// `Watchdog::new(0)` is a no-op — produces a handle that does nothing. This
505/// lets call-sites set a watchdog conditionally without special-casing the
506/// "no timeout" branch.
507/// Optional hook to run from the watchdog thread immediately before
508/// `exit(124)`. Used by `cortex_worker --standalone` to write a
509/// structured `Status:conversion:3` placeholder to `--output` so the
510/// timeout produces a usable failure artifact instead of a missing
511/// file. Set once at startup via `set_pre_exit_hook`; the hook is
512/// invoked exactly once. Zero overhead on the happy path — only the
513/// watchdog firing reads it.
514type PreExitHook = Box<dyn FnOnce() + Send + 'static>;
515
516static PRE_EXIT_HOOK: std::sync::OnceLock<std::sync::Mutex<Option<PreExitHook>>> =
517 std::sync::OnceLock::new();
518
519pub fn set_pre_exit_hook(hook: PreExitHook) {
520 let cell = PRE_EXIT_HOOK.get_or_init(|| std::sync::Mutex::new(None));
521 if let Ok(mut guard) = cell.lock() {
522 *guard = Some(hook);
523 }
524}
525
526fn run_pre_exit_hook() {
527 if let Some(cell) = PRE_EXIT_HOOK.get()
528 && let Ok(mut guard) = cell.lock()
529 && let Some(hook) = guard.take()
530 {
531 hook();
532 }
533}
534
535pub struct Watchdog {
536 cancelled: Arc<AtomicBool>,
537}
538
539impl Watchdog {
540 /// Create a wall-clock-only watchdog. `timeout_secs = 0` disables it.
541 /// Equivalent to [`Watchdog::with_limits(timeout_secs, 0)`].
542 pub fn new(timeout_secs: u64) -> Self { Self::with_limits(timeout_secs, 0) }
543
544 /// Create a watchdog guarding a wall-clock deadline **and** a resident-memory
545 /// ceiling. `timeout_secs = 0` disables the time guard; `max_rss_kb = 0`
546 /// disables the memory guard. With both `0` this is a no-op handle.
547 ///
548 /// The thread polls `cancelled`, the deadline, and RSS every `poll_interval`.
549 /// On a time breach it exits [`EXIT_TIMEOUT`]; on a memory breach,
550 /// [`EXIT_OOM`]. The memory guard is inactive where [`process_rss_kb`]
551 /// returns `None` (non-Linux); see the module portability note.
552 pub fn with_limits(timeout_secs: u64, max_rss_kb: u64) -> Self {
553 let cancelled = Arc::new(AtomicBool::new(false));
554 if timeout_secs > 0 || max_rss_kb > 0 {
555 let c = cancelled.clone();
556 thread::Builder::new()
557 .name("latexml-watchdog".to_string())
558 .spawn(move || Self::run(c, timeout_secs, max_rss_kb))
559 .expect("watchdog thread spawn failed");
560 }
561 Self { cancelled }
562 }
563
564 fn run(cancelled: Arc<AtomicBool>, timeout_secs: u64, max_rss_kb: u64) {
565 let deadline = (timeout_secs > 0).then(|| Instant::now() + Duration::from_secs(timeout_secs));
566 let poll_interval = Duration::from_millis(100);
567 loop {
568 if cancelled.load(Ordering::Relaxed) {
569 return; // cancelled: graceful exit.
570 }
571 if let Some(deadline) = deadline
572 && Instant::now() >= deadline
573 {
574 if cancelled.load(Ordering::Relaxed) {
575 return;
576 }
577 eprintln!(
578 "Fatal:timeout:wallclock latexml-oxide: main-level wall-clock timeout after {timeout_secs}s — exiting process"
579 );
580 // Run the optional pre-exit hook (e.g. cortex_worker writing a
581 // structured Status:conversion:3 placeholder to its --output path)
582 // BEFORE exiting. The hook is invoked at most once per process.
583 run_pre_exit_hook();
584 // `std::process::exit(124)` instead of `abort()`: the watchdog must
585 // terminate the whole process (the worker thread is presumed wedged
586 // in a tight loop that won't observe a cooperative cancel), but
587 // `abort()` produces a "Aborted (core dumped)" SIGABRT trace from
588 // the shell. `exit(124)` (standard timeout exit code) runs atexit
589 // handlers, flushes stderr, and leaves a clean exit signal the
590 // parent harness can interpret as "paper timed out" without
591 // conflating it with a Rust panic / memory corruption. Witnesses:
592 // 2602.11915, 2604.11500, 2604.13944, hep-ph9205242, q-alg9604005,
593 // q-alg9605003, q-alg9605028 — the 7 "Aborted" rows in the
594 // 2026-05-13 588-paper sweep.
595 std::process::exit(EXIT_TIMEOUT);
596 }
597 if max_rss_kb > 0
598 && let Some(rss) = process_rss_kb()
599 && rss > max_rss_kb
600 {
601 if cancelled.load(Ordering::Relaxed) {
602 return;
603 }
604 eprintln!(
605 "Fatal:oom:rss latexml-oxide: resident memory {}MB exceeded the {}MB ceiling — exiting \
606 process. This document needs a larger ceiling: rerun with a higher --max-memory on a \
607 machine with enough free RAM.",
608 rss / 1024,
609 max_rss_kb / 1024
610 );
611 run_pre_exit_hook();
612 std::process::exit(EXIT_OOM);
613 }
614 thread::sleep(poll_interval);
615 }
616 }
617
618 /// Explicitly cancel the watchdog. Idempotent.
619 pub fn cancel(&self) { self.cancelled.store(true, Ordering::Relaxed); }
620}
621
622impl Drop for Watchdog {
623 fn drop(&mut self) { self.cancel(); }
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629
630 /// The cgroup limit decides the ceiling in a container, so each way of
631 /// spelling "unlimited" has to be recognised — otherwise a sentinel is
632 /// mistaken for a 8 EiB budget, or `max` for a parse failure.
633 #[test]
634 fn cgroup_limit_recognises_every_unlimited_spelling() {
635 const PHYS: Option<u64> = Some(258 * 1024 * 1024 * 1024);
636
637 // cgroup v2: the literal word.
638 assert_eq!(interpret_cgroup_limit("max\n", PHYS), None);
639 // cgroup v1: a huge sentinel (u64::MAX rounded to a page multiple).
640 assert_eq!(interpret_cgroup_limit("9223372036854771712", PHYS), None);
641 // A limit at or above physical RAM bounds nothing (258 GiB exactly, and
642 // above).
643 assert_eq!(interpret_cgroup_limit("277025390592", PHYS), None);
644 assert_eq!(interpret_cgroup_limit("281474976710656", PHYS), None);
645 // Garbage is not a limit.
646 assert_eq!(interpret_cgroup_limit("", PHYS), None);
647 assert_eq!(interpret_cgroup_limit("nonsense", PHYS), None);
648 assert_eq!(interpret_cgroup_limit("0", PHYS), None);
649
650 // A REAL limit is honoured — this is the `docker run -m 4g` case that was
651 // being ignored, leaving the process to be OOM-killed at 4 GB while its
652 // cooperative fuse sat at 48 GiB.
653 let four_gib = 4 * 1024 * 1024 * 1024;
654 assert_eq!(
655 interpret_cgroup_limit(&four_gib.to_string(), PHYS),
656 Some(four_gib)
657 );
658 // …and trailing whitespace from the sysfs read must not defeat it.
659 assert_eq!(
660 interpret_cgroup_limit(&format!("{four_gib}\n"), PHYS),
661 Some(four_gib)
662 );
663 }
664
665 #[test]
666 fn watchdog_zero_timeout_is_noop() {
667 // timeout_secs=0 should NOT spawn a thread and NOT abort.
668 let w = Watchdog::new(0);
669 assert!(
670 !w.cancelled.load(Ordering::Relaxed),
671 "initial cancelled state is false"
672 );
673 // Dropping is safe — there's no live thread to interact with.
674 drop(w);
675 }
676
677 #[test]
678 fn watchdog_cancel_is_idempotent() {
679 let w = Watchdog::new(60);
680 w.cancel();
681 assert!(w.cancelled.load(Ordering::Relaxed));
682 // Calling again is a no-op.
683 w.cancel();
684 assert!(w.cancelled.load(Ordering::Relaxed));
685 }
686
687 #[test]
688 fn watchdog_drop_cancels() {
689 let cancelled_ref = {
690 let w = Watchdog::new(60);
691 // Grab a reference to the atomic so we can inspect post-drop.
692 w.cancelled.clone()
693 }; // w dropped here
694 assert!(
695 cancelled_ref.load(Ordering::Relaxed),
696 "drop should set cancelled=true"
697 );
698 }
699
700 #[test]
701 fn watchdog_explicit_cancel_before_drop() {
702 // Pre-drop cancellation is also reflected on the clone.
703 let w = Watchdog::new(60);
704 let cancelled_ref = w.cancelled.clone();
705 w.cancel();
706 assert!(cancelled_ref.load(Ordering::Relaxed));
707 // Explicit drop after cancel remains idempotent.
708 drop(w);
709 assert!(cancelled_ref.load(Ordering::Relaxed));
710 }
711
712 #[test]
713 fn watchdog_long_timeout_doesnt_fire_quickly() {
714 // 60-second timeout shouldn't fire during a 50 ms sleep.
715 let _w = Watchdog::new(60);
716 thread::sleep(Duration::from_millis(50));
717 // If the watchdog had fired, we'd be dead. We made it here → fine.
718 }
719
720 /// The ceiling is derived from the machine, so the machine must be probeable
721 /// on every platform we ship. A `None` here means the default silently falls
722 /// back to a flat number and the ceiling stops tracking the host — the exact
723 /// failure `process_rss_kb` already has on non-Linux.
724 #[test]
725 #[cfg(any(unix, windows))]
726 fn total_memory_is_probeable_and_plausible() {
727 let total = total_memory_bytes().expect("physical RAM must be probeable on unix/windows");
728 // No real machine we support has under 256 MiB, and none has over 100 TiB;
729 // a value outside that says the units are wrong, not that the host is odd.
730 assert!(
731 total > 256 * 1024 * 1024,
732 "implausibly small total RAM ({total} bytes) — check the unit conversion"
733 );
734 assert!(
735 total < 100 * 1024 * 1024 * 1024 * 1024,
736 "implausibly large total RAM ({total} bytes) — check the unit conversion"
737 );
738 }
739
740 /// The live probes compose sanely: never zero (a zero ceiling would mean
741 /// "no limit" downstream, `resolve_rss_cap`), never above the fleet cap,
742 /// never above what the process may use at all. The exact fraction is
743 /// asserted on the pure function below, not here — the live machine's
744 /// availability moves between the probe and any re-probe.
745 #[test]
746 fn default_ceiling_respects_both_halves_of_the_rule() {
747 let ceiling = default_ceiling_mib();
748 assert!(
749 ceiling > 0,
750 "a derived ceiling of 0 would read as 'unlimited'"
751 );
752 assert!(
753 ceiling <= MAX_DEFAULT_CEILING_MIB,
754 "derived {ceiling} MiB exceeds the {MAX_DEFAULT_CEILING_MIB} MiB cap; on a \
755 large host an uncapped fraction-of-RAM default would let a parallel fleet \
756 (N_processes x ceiling) OOM the machine"
757 );
758 if let Some(total) = total_memory_bytes() {
759 assert!(
760 ceiling <= (total / (1024 * 1024)).max(MIN_DEFAULT_CEILING_MIB),
761 "the ceiling ({ceiling} MiB) cannot exceed physical RAM"
762 );
763 }
764 if total_memory_bytes().is_none() && available_memory_bytes().is_none() {
765 assert_eq!(ceiling, FALLBACK_CEILING_MIB);
766 }
767 }
768
769 /// The headroom rule, case by case (user directive 2026-08-01: default to
770 /// actual machine headroom — the 26000-vs-32000 hand-tuning episode on the
771 /// 131 MB witness showed how sharp the manual edge is).
772 #[test]
773 fn ceiling_rule_is_ninety_percent_of_available_with_fallbacks() {
774 const GIB: u64 = 1024 * 1024 * 1024;
775 // Idle 31 GB laptop, ~28 GB available: 90 % of available, NOT half of
776 // total — the difference is the witness converting by default or dying.
777 assert_eq!(derive_ceiling_mib(Some(28 * GIB), Some(31 * GIB)), 25804);
778 // Busy 16 GB laptop with ~9 GB available: the rule self-adjusts to
779 // roughly what half-of-total used to choose.
780 assert_eq!(derive_ceiling_mib(Some(9 * GIB), Some(16 * GIB)), 8294);
781 // Availability above the cgroup-capped total (host-wide MemAvailable seen
782 // from inside a small container): the total binds.
783 assert_eq!(derive_ceiling_mib(Some(28 * GIB), Some(4 * GIB)), 4 * 1024);
784 // No availability probe: the conservative half-of-total fallback.
785 assert_eq!(derive_ceiling_mib(None, Some(16 * GIB)), 8 * 1024);
786 // Huge host: the 64 GiB fleet cap binds.
787 assert_eq!(
788 derive_ceiling_mib(Some(200 * GIB), Some(256 * GIB)),
789 MAX_DEFAULT_CEILING_MIB
790 );
791 // Tiny container: the floor binds rather than deriving an unusable sliver.
792 assert_eq!(
793 derive_ceiling_mib(Some(GIB), Some(2 * GIB)),
794 MIN_DEFAULT_CEILING_MIB
795 );
796 // Nothing probeable: the historical flat default.
797 assert_eq!(derive_ceiling_mib(None, None), FALLBACK_CEILING_MIB);
798 }
799
800 /// The availability probe answers on every CI platform (Linux, macOS,
801 /// Windows) and the figure is plausible: nonzero, and no more than total.
802 #[test]
803 fn available_memory_probe_is_plausible() {
804 let Some(avail) = available_memory_bytes() else {
805 panic!("availability probe must answer on Linux/macOS/Windows CI");
806 };
807 assert!(avail > 0, "zero available RAM on a running machine");
808 if let Some(total) = total_memory_bytes() {
809 assert!(
810 avail <= total,
811 "available ({avail}) cannot exceed total ({total})"
812 );
813 }
814 }
815
816 /// Free-disk probing backs the spill-headroom check; if it cannot answer we
817 /// would have to spill blind.
818 #[test]
819 #[cfg(any(unix, windows))]
820 fn available_disk_is_probeable() {
821 let free = available_disk_bytes(std::path::Path::new("."))
822 .expect("free space must be probeable on unix/windows");
823 assert!(
824 free > 0,
825 "reported zero free space on the working directory"
826 );
827 }
828}