@@ -5,6 +5,8 @@ use std::io::{Read, Write};
55use std::net::{Shutdown, TcpStream};
66use std::path::{Component, Path, PathBuf};
77use std::rc::Rc;
8+ use std::sync::Arc;
9+ use std::sync::atomic::AtomicBool;
810
911use base64::Engine;
1012use chrono::{DateTime, Datelike, NaiveDate, SecondsFormat, TimeZone, Timelike, Utc};
@@ -6341,7 +6343,10 @@ struct TaskSlot {
63416343/// the depth cap is generous (never trips real code, always catches
63426344/// `fn f(){f()}`), and the step/memory budgets are off unless a caller opts in
63436345/// (typically only the untrusted/agent-facing entry points).
6344- #[derive(Debug, Clone, Copy)]
6346+ /// Note: not `Copy` — it carries an optional `Arc<AtomicBool>` cancel flag. All
6347+ /// fields are public and `Clone`/struct-update (`..VmLimits::default()`) keep
6348+ /// callers ergonomic; the scalar budget fields are read by value as before.
6349+ #[derive(Debug, Clone)]
63456350pub struct VmLimits {
63466351 /// Maximum simultaneous call frames (recursion depth). Default-on and
63476352 /// generous; checked before every frame push. `usize::MAX` effectively
@@ -6356,6 +6361,15 @@ pub struct VmLimits {
63566361 /// stacks + list/map growth). `None` (default) = no accounting (near-zero
63576362 /// overhead). See [`RegVm::live_bytes`] for the accounting approximation.
63586363 pub mem_budget: Option<usize>,
6364+ /// Host-level preemption hook. `None` (default) = no polling (the off path is
6365+ /// near-free: `tick()` never touches the atomic). When `Some`, the host can
6366+ /// set the flag to `true` from anywhere (e.g. a watchdog thread on timeout or
6367+ /// an abort signal) and the running evaluation is preempted at the next
6368+ /// throttled step check — even inside a tight `while true {}` loop that never
6369+ /// awaits or checks the cooperative RSS `CancellationToken`. The eval then
6370+ /// returns `EvalError::Runtime("evaluation cancelled")`. This stops the *whole*
6371+ /// eval; see the note in `tick()` on per-task preemption.
6372+ pub cancel: Option<Arc<AtomicBool>>,
63596373}
63606374
63616375/// Default recursion-depth cap: generous enough never to trip real code (deep
@@ -6364,6 +6378,12 @@ pub struct VmLimits {
63646378/// native stack.
63656379const DEFAULT_MAX_DEPTH: usize = 16_384;
63666380
6381+ /// How often `tick()` polls the ambient cancel flag (once every this many
6382+ /// instructions). A power of two so the modulo lowers to a mask. Small enough
6383+ /// that a watchdog preempts a tight loop within microseconds, large enough that
6384+ /// the relaxed atomic load is negligible amortized over real work.
6385+ const CANCEL_POLL_INTERVAL: u64 = 1024;
6386+
63676387/// Estimated bytes charged per list element for the best-effort memory ceiling.
63686388/// Each list slot stores one `VmValue` inline (the heap pointed at by `Rc`
63696389/// containers is counted again when *those* grow, so this under-counts deeply
@@ -6381,6 +6401,7 @@ impl Default for VmLimits {
63816401 max_depth: DEFAULT_MAX_DEPTH,
63826402 step_budget: None,
63836403 mem_budget: None,
6404+ cancel: None,
63846405 }
63856406 }
63866407}
@@ -6804,6 +6825,22 @@ impl RegVm {
68046825 /// is off), and — only when `limits.step_budget` is `Some` — trips once the
68056826 /// count exceeds the limit. This is what stops an infinite loop (`while true
68066827 /// {}`) from hanging the host: it returns a clean error instead.
6828+ ///
6829+ /// It is also the host-level *preemption* hook. When `limits.cancel` is
6830+ /// `Some`, every `CANCEL_POLL_INTERVAL` steps we load the ambient
6831+ /// `AtomicBool` (`Relaxed` — we only need eventual visibility, not ordering)
6832+ /// and, if set, abort the eval with `EvalError::Runtime("evaluation
6833+ /// cancelled")`. The throttle keeps both the off path (no atomic touched at
6834+ /// all) and the on path (one relaxed load per 1024 instructions) cheap, so a
6835+ /// tight loop stays fast while still being interruptible by a watchdog.
6836+ ///
6837+ /// Limitation: this stops the *entire* evaluation. Preemptively cancelling a
6838+ /// single *sibling* task stuck in a tight loop — so a `select`/`task_group`
6839+ /// can reach its winner while one branch spins — would require the scheduler
6840+ /// to yield mid-instruction-stream (snapshot a `SavedTask` at an arbitrary
6841+ /// `ip` and reschedule), a deeper redesign that is out of scope here. The RSS
6842+ /// `CancellationToken` remains the cooperative, per-task mechanism (it only
6843+ /// preempts at await points); this ambient flag is the blunt host-level kill.
68076844 #[inline]
68086845 fn tick(&mut self) -> Result<(), EvalError> {
68096846 self.steps += 1;
@@ -6814,6 +6851,12 @@ impl RegVm {
68146851 "step budget exceeded ({limit} instructions)"
68156852 )));
68166853 }
6854+ if self.steps.is_multiple_of(CANCEL_POLL_INTERVAL)
6855+ && let Some(flag) = self.limits.cancel.as_ref()
6856+ && flag.load(std::sync::atomic::Ordering::Relaxed)
6857+ {
6858+ return Err(EvalError::Runtime("evaluation cancelled".into()));
6859+ }
68176860 Ok(())
68186861 }
68196862
@@ -9483,6 +9526,11 @@ impl RegVm {
94839526 }
94849527 RegIntrinsic::CapabilityFrom => Ok(intrinsic_arg(&self.stack, base, args, 0)?.clone()),
94859528 RegIntrinsic::CancellationSourceCancel => {
9529+ // RSS-level *cooperative* cancellation: flips the program-visible
9530+ // flag a task must poll (e.g. `token.is_cancelled()`); it preempts
9531+ // only at await/poll points, not inside a tight compute loop. The
9532+ // host-level *preemptive* hook for a runaway loop is the ambient
9533+ // `limits.cancel` atomic polled by `tick()` — see `RegVm::tick`.
94869534 let id = expect_cancellation_id_ref(
94879535 intrinsic_arg(&self.stack, base, args, 0)?,
94889536 "CancellationSource",
0 commit comments