Skip to main content

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}