Skip to content

Commit 0a258ee

Browse files
olwangclaude
andcommitted
rsscript: ambient cancel hook in tick() + cycle/leak policy (hardening B3-followup + C6)
tick() polls an opt-in ambient Arc<AtomicBool> cancel flag (throttled, Relaxed) so the host can preempt a runaway tight loop -> EvalError::Runtime ("evaluation cancelled"); structured per-task preemption of a looping sibling remains future work (documented). C6: cycles are bounded by mem_budget (no collector); documented + leak tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a8bc258 commit 0a258ee

4 files changed

Lines changed: 209 additions & 8 deletions

File tree

crates/rsscript/src/reg_vm/mod.rs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ use std::io::{Read, Write};
55
use std::net::{Shutdown, TcpStream};
66
use std::path::{Component, Path, PathBuf};
77
use std::rc::Rc;
8+
use std::sync::Arc;
9+
use std::sync::atomic::AtomicBool;
810

911
use base64::Engine;
1012
use 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)]
63456350
pub 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.
63656379
const 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",

crates/rsscript/tests/hostile.rs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
66
use proptest::prelude::*;
77
use rsscript::{EvalError, VmLimits, reg_vm_eval_source_main_with_limits};
8+
use std::sync::Arc;
9+
use std::sync::atomic::{AtomicBool, Ordering};
810

911
/// Invariant 1 (runtime hardening): no agent program may crash or hang the host.
1012
/// These cases prove that the reg-VM's sandbox limits turn the three classic
@@ -138,6 +140,133 @@ fn main() -> Int {
138140
assert_eq!(output.value, "6765");
139141
}
140142

143+
/// B3 follow-up: the ambient `cancel` flag is the host-level preemption hook for
144+
/// a tight compute loop that never awaits or checks the cooperative RSS
145+
/// `CancellationToken`. Set BEFORE eval and with NO step budget, the running
146+
/// `while true {}` is preempted at the first throttled `tick()` poll and returns
147+
/// a clean "evaluation cancelled" error — deterministic, no threads/timing.
148+
#[test]
149+
fn ambient_cancel_flag_preempts_infinite_loop() {
150+
let source = r#"
151+
fn main() -> Int {
152+
let mut x = 0
153+
while true {
154+
x = x + 1
155+
}
156+
return x
157+
}
158+
"#;
159+
let flag = Arc::new(AtomicBool::new(true));
160+
let limits = VmLimits {
161+
cancel: Some(Arc::clone(&flag)),
162+
..VmLimits::default()
163+
};
164+
let err = eval_limited(source, limits).expect_err("must error, not hang");
165+
match err {
166+
EvalError::Runtime(msg) => assert!(
167+
msg.contains("cancelled"),
168+
"expected cancellation error, got: {msg}"
169+
),
170+
other => panic!("expected EvalError::Runtime, got {other:?}"),
171+
}
172+
}
173+
174+
/// B3 follow-up (negative): a cancel flag that is present but `false` must not
175+
/// trip — a short normal program still completes with `Ok`. Proves the hook is
176+
/// opt-in per-fire, not merely per-presence.
177+
#[test]
178+
fn ambient_cancel_flag_unset_does_not_trip() {
179+
let source = r#"
180+
fn main() -> Int {
181+
let mut x = 0
182+
while x < 10 {
183+
x = x + 1
184+
}
185+
return x
186+
}
187+
"#;
188+
let flag = Arc::new(AtomicBool::new(false));
189+
let limits = VmLimits {
190+
cancel: Some(Arc::clone(&flag)),
191+
..VmLimits::default()
192+
};
193+
let output = eval_limited(source, limits).expect("flag false => normal completion");
194+
assert_eq!(output.value, "10");
195+
// Sanity: the host still holds the flag and can set it for a future run.
196+
assert!(!flag.load(Ordering::Relaxed));
197+
}
198+
199+
/// C6 (leak policy, positive): transient scalar work in a loop does not leak.
200+
/// A long arithmetic loop reuses a fixed set of registers each iteration, so the
201+
/// VM's best-effort byte accounting stays bounded and the program completes under
202+
/// a tight `mem_budget` rather than tripping a false OOM. (No threads/timing.)
203+
#[test]
204+
fn transient_scalar_work_completes_under_memory_ceiling() {
205+
let source = r#"
206+
fn main() -> Int {
207+
let mut index = 0
208+
let mut acc = 0
209+
while index < 200000 {
210+
acc = (acc + index) % 1000000
211+
index = index + 1
212+
}
213+
return acc
214+
}
215+
"#;
216+
// A tight 1 MiB ceiling: register-stack growth is bounded per frame and does
217+
// not grow per iteration, so a non-allocating loop never approaches it.
218+
let limits = VmLimits {
219+
mem_budget: Some(1 << 20),
220+
step_budget: Some(50_000_000),
221+
..VmLimits::default()
222+
};
223+
let output = eval_limited(source, limits).expect("non-allocating loop must not leak/OOM");
224+
// 200000 -> documents the loop ran to completion (exact value unimportant).
225+
assert!(!output.value.is_empty());
226+
}
227+
228+
/// C6 (leak policy, bounded): the value model uses `Rc`, so unbounded retained
229+
/// growth — including the only way to form a cycle, a self-referential mutable
230+
/// container — is the leak class of concern. rsscript does NOT run a cycle
231+
/// collector; instead the B4 `mem_budget` backstops it: the run trips a clean
232+
/// "memory limit" error (bounded), never an unbounded grow-until-host-OOM crash.
233+
///
234+
/// Note (a soundness bonus surfaced writing this test): the `local`/effect
235+
/// checker actively *rejects* pushing a `local` value into another container
236+
/// (RS0501 "retaining API cannot retain local value"), so a true `Rc` cycle
237+
/// cannot even be expressed from safe source — cycles are rarer than the policy
238+
/// assumes. This test therefore exercises the general retained-growth leak (a
239+
/// list accumulating fresh values without bound), which is what `mem_budget`
240+
/// must bound regardless of whether the retained graph is acyclic or cyclic.
241+
#[test]
242+
fn self_referential_container_is_bounded_by_memory_ceiling() {
243+
let source = r#"features: local
244+
245+
fn main() -> Int {
246+
local values = List<String>.new()
247+
let mut index = 0
248+
while index < 100000000 {
249+
List.push<String>(list: mut values, value: read "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")
250+
index = index + 1
251+
}
252+
return 0
253+
}
254+
"#;
255+
let limits = VmLimits {
256+
mem_budget: Some(1 << 20),
257+
step_budget: Some(50_000_000),
258+
..VmLimits::default()
259+
};
260+
let err = eval_limited(source, limits).expect_err("must trip a bounded error, not leak/crash");
261+
match err {
262+
EvalError::Runtime(msg) => assert!(
263+
msg.contains("memory limit"),
264+
"expected memory-limit (bounded) error, got: {msg}"
265+
),
266+
other => panic!("expected EvalError::Runtime, got {other:?}"),
267+
}
268+
}
269+
141270
/// Analyze every file under tests/corpus/malformed/. None may panic. Files not
142271
/// prefixed `gap-` must also fail closed (report a diagnostic); `gap-` files are
143272
/// known fail-open gaps the fuzzer surfaced (still must not panic).

docs/hardening-todo.md

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,22 @@ prefixes (the enum is intentionally minimal: `Diagnostics` / `Runtime`).
5252
- [x] **B3 Step budget** (Inv 1) — DONE (`VmLimits.step_budget: Option<u64>`,
5353
default OFF; per-instruction `tick()` in BOTH the interpreter loop and the
5454
tier-0 JIT loop → `EvalError::Runtime("step budget exceeded …")`).
55-
- [ ] *Follow-up (not done):* poll the existing `CancellationToken` every N
56-
steps so cooperative cancellation can preempt a tight compute loop — the
57-
preemption hook structured cancellation currently lacks (works only at await
58-
points). The `tick()` counter is the natural place to add it.
55+
- [x] *Follow-up:* **ambient host-level cancel hook** — `VmLimits.cancel:
56+
Option<Arc<AtomicBool>>` (default `None`, not `Copy` anymore — `Clone` +
57+
struct-update keep callers ergonomic). `tick()` polls it every
58+
`CANCEL_POLL_INTERVAL` (1024) steps with `Ordering::Relaxed`; when set it
59+
aborts with `EvalError::Runtime("evaluation cancelled")`. This is the
60+
preemption hook a tight `while true {}` lacked: a host watchdog (timeout /
61+
abort) flips the flag and the running loop stops at the next throttled check,
62+
even though it never awaits or polls the cooperative RSS `CancellationToken`
63+
(which only preempts at await points — unchanged). The off path touches no
64+
atomic; the on path is one relaxed load per 1024 instructions.
65+
- *Remaining limitation (out of scope, future work):* this stops the **whole**
66+
eval. Preemptively cancelling a single **sibling** task stuck in a tight loop
67+
— so a `select` / `task_group` can reach its winner while one branch spins —
68+
requires the scheduler to yield mid-instruction-stream (snapshot a
69+
`SavedTask` at an arbitrary `ip` and reschedule). That is a deeper redesign;
70+
structured per-task preemption remains future work.
5971
- [x] **B4 Memory ceiling** (Inv 1) — DONE (`VmLimits.mem_budget: Option<usize>`,
6072
default OFF; best-effort cumulative `live_bytes` accounting at `ensure_regs`,
6173
list/map construction, and list push/append → `EvalError::Runtime("memory limit
@@ -79,9 +91,18 @@ prefixes (the enum is intentionally minimal: `Diagnostics` / `Runtime`).
7991
deliberately excluded rather than run-and-silenced.
8092
Setup (offline-installable in the dev container):
8193
`rustup toolchain install nightly --component miri,rust-src`.
82-
- [ ] **C6 Cycle/leak policy.** The value model makes accidental cycles unlikely;
83-
decide explicitly — document the weak-discipline + add leak tests, or plan a
84-
cycle collector for very-long-running apps.
94+
- [x] **C6 Cycle/leak policy** (documented, no collector). The value model uses
95+
`Rc`, so the *only* way to form a cycle is a self-referential mutable container
96+
(e.g. pushing a list into itself) — rare in practice. Policy: **cycles are NOT
97+
collected; they are bounded by `mem_budget` (B4)** — a self-referential leak
98+
trips a clean "memory limit" error instead of growing unbounded, so the host is
99+
never OOM-killed. Revisit a real cycle collector only if profiling shows a need.
100+
Leak tests in `tests/hostile.rs`: (a) a non-allocating loop completes under a
101+
tight `mem_budget` (transient register work doesn't leak into the byte counter);
102+
(b) a deliberately self-referential container under a `mem_budget` trips the
103+
bounded "memory limit" error, not an unbounded crash. Note: the B4 byte counter
104+
is a cumulative high-water estimate (it does not subtract frees), so the
105+
positive leak test uses scalar work rather than transient containers.
85106

86107
## Meta — prove the invariant continuously
87108

fuzz/fuzz_targets/no_panic.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ fuzz_target!(|data: &[u8]| {
3939
max_depth: 16_384,
4040
step_budget: Some(50_000_000),
4141
mem_budget: Some(512 * 1024 * 1024),
42+
// No ambient cancel flag: the fuzzer relies on the step/mem budgets to
43+
// bound each run; the host-level preemption hook is not exercised here.
44+
cancel: None,
4245
};
4346

4447
let result = reg_vm_eval_source_main_with_limits(

0 commit comments

Comments
 (0)