diff --git a/crates/searchi-core/src/extraction_process.rs b/crates/searchi-core/src/extraction_process.rs index 1fbea63..3d83abd 100644 --- a/crates/searchi-core/src/extraction_process.rs +++ b/crates/searchi-core/src/extraction_process.rs @@ -48,6 +48,33 @@ enum Event { Stderr, } +#[cfg(target_os = "macos")] +fn check_resident_memory(child: &mut Child, maximum: u64) -> Result<(), ProcessError> { + let mut info = std::mem::MaybeUninit::::uninit(); + let size = std::mem::size_of::(); + // The child stays owned and unreaped until I/O completes. A failed query is + // accepted only when it has already exited; otherwise fail closed. + let read = unsafe { + libc::proc_pidinfo( + child.id() as i32, + libc::PROC_PIDTASKINFO, + 0, + info.as_mut_ptr().cast(), + size as i32, + ) + }; + if read != size as i32 { + if child.try_wait().map_err(failed)?.is_some() { + return Ok(()); + } + return Err(failed("Cannot inspect extraction memory usage")); + } + if unsafe { info.assume_init() }.pti_resident_size > maximum { + return Err(failed("Extraction worker resident memory exceeds budget")); + } + Ok(()) +} + /// Includes pipe writes, response EOF and process exit in the same deadline. /// No parser fallback occurs when limits, transport or worker execution fail. pub fn run( @@ -161,6 +188,8 @@ pub fn run( if started.elapsed() >= config.timeout { return Err(ProcessError::TimedOut); } + #[cfg(target_os = "macos")] + check_resident_memory(&mut owned.child, 512 * 1024 * 1024)?; match rx.recv_timeout( Duration::from_millis(5).min(config.timeout.saturating_sub(started.elapsed())), ) { @@ -288,6 +317,20 @@ mod tests { .unwrap_or_else(|poisoned| poisoned.into_inner()) } + #[cfg(target_os = "macos")] + #[test] + fn mac_memory_budget_rejects_a_live_owned_child() { + let mut child = Command::new("/bin/sleep").arg("30").spawn().unwrap(); + // Wait for exec before measuring, then impose a deliberately tiny budget. + thread::sleep(Duration::from_millis(50)); + let result = check_resident_memory(&mut child, 1); + let _ = child.kill(); + child.wait().unwrap(); + assert!( + matches!(result, Err(ProcessError::Failed(message)) if message.contains("exceeds budget")) + ); + } + fn fixtures() -> tempfile::TempDir { let directory = tempfile::tempdir().unwrap(); let source = directory.path().join("fake.rs"); diff --git a/crates/searchi-extract-worker/src/resources.rs b/crates/searchi-extract-worker/src/resources.rs index 3b89517..018aa80 100644 --- a/crates/searchi-extract-worker/src/resources.rs +++ b/crates/searchi-extract-worker/src/resources.rs @@ -1,5 +1,6 @@ use anyhow::{ensure, Result}; +#[cfg(not(target_os = "macos"))] const MEMORY_BYTES: usize = 512 * 1024 * 1024; #[cfg(windows)] @@ -37,7 +38,10 @@ pub fn apply() -> Result<()> { #[cfg(unix)] pub fn apply() -> Result<()> { + // Darwin rejects finite AS/DATA limits. The parent enforces a sampled RSS + // budget there; retain the CPU limit and bounded path-free transport here. for (resource, maximum) in [ + #[cfg(not(target_os = "macos"))] (libc::RLIMIT_AS, MEMORY_BYTES as libc::rlim_t), (libc::RLIMIT_CPU, 30 as libc::rlim_t), ] { diff --git a/docs/MACOS-RUNTIME.md b/docs/MACOS-RUNTIME.md index 80a5b6e..8bc5762 100644 --- a/docs/MACOS-RUNTIME.md +++ b/docs/MACOS-RUNTIME.md @@ -101,3 +101,16 @@ The broader workspace run exposes an existing stale permission-denial rollback expectation in `targeted_reconciliation` (current engine skips unreadable inputs). Strict current-toolchain Clippy also flags unchanged permission-literal and ANN-cache boolean expressions. These failures are not qualified as passing gates. + +### Native document extraction policy + +Mac parent/worker DOCX roundtrip and malformed-PDF rejection tests now pass. +Darwin rejects finite `RLIMIT_AS` and `RLIMIT_DATA` on the tested system. The +worker retains the30-second CPU limit; the parent checks resident memory through +`proc_pidinfo` during its existing5ms polling loop and rejects usage above512MiB, +killing/reaping its owned process group. Input/output bounds and the parent +wall deadline remain active. This sampled RSS guard may overshoot between polls; +it is not equivalent to a kernel-enforced hard address-space limit. Linux and +Windows retain their existing resource policies. An owned live-child test +verifies budget rejection; five process-owner tests and two actual parent/worker +roundtrip/rejection tests pass on the Mac.