diff --git a/crates/trawler-core/src/query.rs b/crates/trawler-core/src/query.rs index c8cfd26..8ad94ae 100644 --- a/crates/trawler-core/src/query.rs +++ b/crates/trawler-core/src/query.rs @@ -118,17 +118,29 @@ pub fn eval_query( let _ = result_tx.send(outcome); }); - let Ok(controller) = controller_rx.recv() else { - return QueryOutcome::Errored(QueryError { - message: "query VM thread panicked before starting".into(), - span: None, - }); - }; - + // The budget covers engine construction too: `build_engine` loads the + // Steel prelude and registers every primitive, which can alone exceed + // a small budget on a starved machine — and the timeout contract + // (spec: "Runaway query containment" — terminated and reported near + // the budget) must hold regardless. So never await the controller + // unboundedly; collect it opportunistically at timeout, leaving a + // reaper behind if the engine is still constructing so the VM thread + // can't spin forever unobserved. (Same shape as the S2 spike in + // query_spike.rs, kept in lockstep.) match result_rx.recv_timeout(budget) { Ok(outcome) => outcome, Err(mpsc::RecvTimeoutError::Timeout) => { - controller.interrupt(); + match controller_rx.try_recv() { + Ok(controller) => controller.interrupt(), + Err(mpsc::TryRecvError::Empty) => { + thread::spawn(move || { + if let Ok(controller) = controller_rx.recv() { + controller.interrupt(); + } + }); + } + Err(mpsc::TryRecvError::Disconnected) => {} + } QueryOutcome::TimedOut } Err(mpsc::RecvTimeoutError::Disconnected) => QueryOutcome::Errored(QueryError { diff --git a/crates/trawler-core/src/query_spike.rs b/crates/trawler-core/src/query_spike.rs index 59494e9..e224406 100644 --- a/crates/trawler-core/src/query_spike.rs +++ b/crates/trawler-core/src/query_spike.rs @@ -63,14 +63,28 @@ pub fn eval_with_timeout(expr: &str, budget: Duration) -> QueryOutcome { }); }); - let Ok(controller) = controller_rx.recv() else { - return QueryOutcome::Errored("VM thread panicked before starting".into()); - }; - + // The budget covers engine construction too: `Engine::new()` loads the + // Steel prelude and can alone exceed a small budget on a starved + // machine (seen in CI: ~4s), and the caller must regain control near + // the budget regardless — the exact claim this spike exists to prove. + // So never await the controller unboundedly; collect it + // opportunistically at timeout, leaving a reaper behind if the engine + // is still constructing so the VM thread can't spin forever + // unobserved. match result_rx.recv_timeout(budget) { Ok(outcome) => outcome, Err(mpsc::RecvTimeoutError::Timeout) => { - controller.interrupt(); + match controller_rx.try_recv() { + Ok(controller) => controller.interrupt(), + Err(mpsc::TryRecvError::Empty) => { + thread::spawn(move || { + if let Ok(controller) = controller_rx.recv() { + controller.interrupt(); + } + }); + } + Err(mpsc::TryRecvError::Disconnected) => {} + } QueryOutcome::TimedOut } Err(mpsc::RecvTimeoutError::Disconnected) => {