Skip to content

Commit 3c48278

Browse files
olwangclaude
andcommitted
fix(reg-vm): recursive native dispatch must honor step_budget/cancel gate
The recursive native fast paths in the CallKnown handler (self-recursive run_jit_self_recursive_int and mutual-recursive try_native_mutual_recursive_int) were gated only on mem_budget.is_none(), not step_budget or cancel. Cranelift code polls neither the step budget nor the cancel flag, so a recursive native candidate (fib, is_even/is_odd) could run to completion and silently bypass preemption that the host armed -- exactly what the normal native-tier gate in try_native already refuses for (it checks all three: step_budget, cancel, mem_budget). This is a sandbox-soundness gap: a watchdog/step limit would not stop native recursion. Fix: factor a single `RegVm::native_limits_unarmed()` (step_budget.is_none() && cancel.is_none() && mem_budget.is_none()) and gate both recursive hooks on it; dedup the try_native gate to `!native_limits_unarmed()` so there is one source of truth. With any limit armed, recursion runs on the interpreter / tier-0 executor, which tick()s every instruction (and tick() polls both step_budget and cancel). Test plumbing: `eval_main_with_args_native_with_limits` threads VmLimits into the native eval path. Regressions (jit_acceptance): - native_self_recursion_refused_when_step_budget_armed (native_calls == 0) - native_mutual_recursion_refused_when_step_budget_armed (native_calls == 0) - native_recursion_refused_when_cancel_armed (present, un-triggered flag) - native_self_recursion_step_budget_preempts (small budget actually trips) Verified: jit_acceptance 92/0, differential 33/0, vm-jit 83/0, hostile 16/0, static 627/0, default build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9c9a733 commit 3c48278

4 files changed

Lines changed: 165 additions & 7 deletions

File tree

crates/rsscript/src/reg_vm/exec.rs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,18 @@ impl RegVm {
113113
/// `CancellationToken` remains the cooperative, per-task mechanism (it only
114114
/// preempts at await points); this ambient flag is the blunt host-level kill.
115115
#[inline]
116+
/// Whether it is sound to dispatch Cranelift-native code right now: native code
117+
/// polls neither the step budget nor the cancel flag and runs allocation off the
118+
/// memory meter, so all three preemption/accounting limits must be unarmed (it
119+
/// `tick()`s on the interpreter/tier-0 paths instead). The single source of truth
120+
/// for both the native-tier gate (`try_native`) and the recursive native fast
121+
/// paths (self-recursive + mutual-recursive); see execution spec §6.2 (Model A).
122+
pub(super) fn native_limits_unarmed(&self) -> bool {
123+
self.limits.step_budget.is_none()
124+
&& self.limits.cancel.is_none()
125+
&& self.limits.mem_budget.is_none()
126+
}
127+
116128
pub(super) fn tick(&mut self) -> Result<(), EvalError> {
117129
self.steps += 1;
118130
if let Some(limit) = self.limits.step_budget
@@ -1310,9 +1322,15 @@ impl RegVm {
13101322
args,
13111323
mut_args,
13121324
} => {
1325+
// Recursive native fast paths run Cranelift code that polls
1326+
// neither `step_budget` nor `cancel` (and allocates off the
1327+
// `mem_budget` meter), so they are gated on all three limits
1328+
// being unarmed — matching the native-tier gate in
1329+
// `try_native`. With any limit armed, recursion runs on the
1330+
// interpreter / tier-0 executor, which `tick()`s every step.
13131331
if self.jit_enabled
13141332
&& mut_args.is_empty()
1315-
&& self.limits.mem_budget.is_none()
1333+
&& self.native_limits_unarmed()
13161334
&& let Some(value) =
13171335
self.run_jit_self_recursive_int(unit, *callee_id, base, args)?
13181336
{
@@ -1325,7 +1343,7 @@ impl RegVm {
13251343
#[cfg(feature = "native-jit")]
13261344
if self.jit_enabled
13271345
&& mut_args.is_empty()
1328-
&& self.limits.mem_budget.is_none()
1346+
&& self.native_limits_unarmed()
13291347
&& let Some(value) =
13301348
self.try_native_mutual_recursive_int(unit, *callee_id, base, args)
13311349
{

crates/rsscript/src/reg_vm/mod.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1445,7 +1445,7 @@ impl RegVmExecutable {
14451445
args: impl IntoIterator<Item = impl Into<String>>,
14461446
) -> Result<(EvalOutput, NativeStats, Vec<String>), EvalError> {
14471447
self.eval_main_with_args_native_inner_reported(
1448-
args, 0, false, true, true, true, true, None, false,
1448+
args, 0, false, true, true, true, true, None, false, VmLimits::default(),
14491449
)
14501450
}
14511451

@@ -1471,6 +1471,23 @@ impl RegVmExecutable {
14711471
false,
14721472
forced_safepoint,
14731473
force_all_safepoints_override,
1474+
VmLimits::default(),
1475+
)
1476+
.map(|(output, stats, _lines)| (output, stats))
1477+
}
1478+
1479+
/// Like [`Self::eval_main_with_args_native_with_stats`] but runs under explicit
1480+
/// [`VmLimits`]. With native enabled, an armed `step_budget`/`cancel`/`mem_budget`
1481+
/// must prevent native dispatch (Cranelift polls/accounts none of them) — used to
1482+
/// regression-test the recursive native fast-path limit gate.
1483+
#[cfg(feature = "native-jit")]
1484+
pub fn eval_main_with_args_native_with_limits(
1485+
&self,
1486+
args: impl IntoIterator<Item = impl Into<String>>,
1487+
limits: VmLimits,
1488+
) -> Result<(EvalOutput, NativeStats), EvalError> {
1489+
self.eval_main_with_args_native_inner_reported(
1490+
args, 0, false, true, true, false, false, None, false, limits,
14741491
)
14751492
.map(|(output, stats, _lines)| (output, stats))
14761493
}
@@ -1488,12 +1505,17 @@ impl RegVmExecutable {
14881505
report_override: bool,
14891506
forced_safepoint: Option<u32>,
14901507
force_all_safepoints_override: bool,
1508+
limits: VmLimits,
14911509
) -> Result<(EvalOutput, NativeStats, Vec<String>), EvalError> {
14921510
let mut vm = RegVm::new(
14931511
Rc::clone(&self.unit),
14941512
args.into_iter().map(Into::into).collect(),
14951513
std::iter::empty::<(String, NativeInterpreterFn)>().collect(),
14961514
);
1515+
// Limits gate native dispatch: when any preemption/accounting limit is armed,
1516+
// `native_limits_unarmed()` refuses native (incl. the recursive fast paths) so
1517+
// the interpreter/tier-0 path enforces it via `tick()`.
1518+
vm.set_limits(limits);
14971519
// Native first, then tier-0, then interpreter.
14981520
// `RSS_JIT_BASELINE=1` selects the Phase-2 path-B baseline tier
14991521
// (`opt_level="none"`); default (unset) keeps the optimizing tier

crates/rsscript/src/reg_vm/tier.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -868,10 +868,7 @@ impl RegVm {
868868
// `mem_budget`, so a function could grow the accounted live-set past the
869869
// limit without erroring. Refusing native while `mem_budget` is armed keeps
870870
// the limit exact (Model A; matches the tier-0 self-recursive gate).
871-
if self.limits.step_budget.is_some()
872-
|| self.limits.cancel.is_some()
873-
|| self.limits.mem_budget.is_some()
874-
{
871+
if !self.native_limits_unarmed() {
875872
return NativeAttempt::Fallback;
876873
}
877874
// Cheap negative path: a function known not native-eligible never compiles,

crates/rsscript/tests/jit_acceptance.rs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4693,3 +4693,124 @@ fn main() -> Unit {
46934693
"shallow Bool mutual recursion should run natively: {stats:?}",
46944694
);
46954695
}
4696+
4697+
// ---------------------------------------------------------------------------
4698+
// Recursive native fast paths must honor the same limit gate as `try_native`:
4699+
// Cranelift code polls neither `step_budget` nor `cancel` and allocates off the
4700+
// `mem_budget` meter, so with any of them armed the recursive paths must NOT
4701+
// dispatch natively (they run on the interpreter / tier-0, which `tick()`s).
4702+
// ---------------------------------------------------------------------------
4703+
4704+
const FIB_SELF_SRC: &str = "\
4705+
fn fib(n: Int) -> Int {
4706+
if n < 2 { return n }
4707+
return fib(n: read n - 1) + fib(n: read n - 2)
4708+
}
4709+
fn main() -> Unit {
4710+
Log.write(message: read String.from_int(value: fib(n: read 28)))
4711+
return Unit
4712+
}
4713+
";
4714+
4715+
const IS_EVEN_MUTUAL_SRC: &str = "\
4716+
fn is_even(n: Int) -> Int {
4717+
if n < 1 { return 1 }
4718+
return is_odd(n: read n - 1)
4719+
}
4720+
fn is_odd(n: Int) -> Int {
4721+
if n < 1 { return 0 }
4722+
return is_even(n: read n - 1)
4723+
}
4724+
fn main() -> Unit {
4725+
Log.write(message: read String.from_int(value: is_even(n: read 20)))
4726+
return Unit
4727+
}
4728+
";
4729+
4730+
/// Self-recursive native candidate (`fib`) must NOT dispatch natively while a
4731+
/// `step_budget` is armed (native never `tick()`s, so it would bypass the budget).
4732+
/// The budget here is generous enough to complete on the interpreter; the proof is
4733+
/// `native_calls == 0` (native refused) with the correct result.
4734+
#[cfg(feature = "native-jit")]
4735+
#[test]
4736+
fn native_self_recursion_refused_when_step_budget_armed() {
4737+
let exe = rsscript::reg_vm_compile_source("limit-self.rss", FIB_SELF_SRC).expect("compile");
4738+
let limits = rsscript::VmLimits {
4739+
step_budget: Some(50_000_000),
4740+
..rsscript::VmLimits::default()
4741+
};
4742+
let (out, stats) = exe
4743+
.eval_main_with_args_native_with_limits(std::iter::empty::<String>(), limits)
4744+
.expect("completes within budget");
4745+
assert_eq!(out.stdout.trim_end(), "317811");
4746+
assert_eq!(
4747+
stats.native_calls, 0,
4748+
"armed step_budget must refuse native self-recursion: {stats:?}"
4749+
);
4750+
}
4751+
4752+
/// Mutual-recursion native candidate (`is_even`/`is_odd`) must NOT dispatch natively
4753+
/// while a `step_budget` is armed.
4754+
#[cfg(feature = "native-jit")]
4755+
#[test]
4756+
fn native_mutual_recursion_refused_when_step_budget_armed() {
4757+
let exe =
4758+
rsscript::reg_vm_compile_source("limit-mutual.rss", IS_EVEN_MUTUAL_SRC).expect("compile");
4759+
let limits = rsscript::VmLimits {
4760+
step_budget: Some(50_000_000),
4761+
..rsscript::VmLimits::default()
4762+
};
4763+
let (out, stats) = exe
4764+
.eval_main_with_args_native_with_limits(std::iter::empty::<String>(), limits)
4765+
.expect("completes within budget");
4766+
assert_eq!(out.stdout.trim_end(), "1");
4767+
assert_eq!(
4768+
stats.native_calls, 0,
4769+
"armed step_budget must refuse native mutual recursion: {stats:?}"
4770+
);
4771+
}
4772+
4773+
/// A present `cancel` flag (even un-triggered) must refuse recursive native dispatch,
4774+
/// matching `try_native` — native code can never observe the flag, so the cooperative
4775+
/// interpreter path (which polls it in `tick()`) must run instead.
4776+
#[cfg(feature = "native-jit")]
4777+
#[test]
4778+
fn native_recursion_refused_when_cancel_armed() {
4779+
let exe = rsscript::reg_vm_compile_source("limit-cancel.rss", FIB_SELF_SRC).expect("compile");
4780+
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
4781+
let limits = rsscript::VmLimits {
4782+
cancel: Some(cancel),
4783+
..rsscript::VmLimits::default()
4784+
};
4785+
let (out, stats) = exe
4786+
.eval_main_with_args_native_with_limits(std::iter::empty::<String>(), limits)
4787+
.expect("completes (flag never set)");
4788+
assert_eq!(out.stdout.trim_end(), "317811");
4789+
assert_eq!(
4790+
stats.native_calls, 0,
4791+
"an armed cancel flag must refuse native recursion: {stats:?}"
4792+
);
4793+
}
4794+
4795+
/// Enforcement, not just refusal: a small `step_budget` must actually PREEMPT a
4796+
/// recursive native candidate (with the bug, native ran `fib(28)` to completion and
4797+
/// silently bypassed the budget). Now it runs on the interpreter and trips the budget.
4798+
#[cfg(feature = "native-jit")]
4799+
#[test]
4800+
fn native_self_recursion_step_budget_preempts() {
4801+
let exe = rsscript::reg_vm_compile_source("limit-preempt.rss", FIB_SELF_SRC).expect("compile");
4802+
let limits = rsscript::VmLimits {
4803+
step_budget: Some(1_000),
4804+
..rsscript::VmLimits::default()
4805+
};
4806+
let err = exe
4807+
.eval_main_with_args_native_with_limits(std::iter::empty::<String>(), limits)
4808+
.expect_err("small step budget must preempt, not be bypassed by native");
4809+
match err {
4810+
rsscript::EvalError::Runtime(msg) => assert!(
4811+
msg.contains("step budget"),
4812+
"expected step-budget error, got: {msg}"
4813+
),
4814+
other => panic!("expected Runtime(step budget), got {other:?}"),
4815+
}
4816+
}

0 commit comments

Comments
 (0)