Skip to content

feat(engine): add HostHooks::check_interrupt and cumulative loop limit accounting - #5642

Open
jbingham wants to merge 1 commit into
boa-dev:mainfrom
jbingham:feat/vm-interrupt-and-loop-limits
Open

jbingham wants to merge 1 commit into
boa-dev:mainfrom
jbingham:feat/vm-interrupt-and-loop-limits

Conversation

@jbingham

@jbingham jbingham commented Nov 6, 2026

Copy link
Copy Markdown

This Pull Request relates to #3442.

It changes the following:

  • Adds HostHooks::check_interrupt(&self) -> JsResult<()> (defaulting to Ok(())) and RuntimeLimitError::Interrupt, checked on Opcode::IncrementLoopIteration and in Context::check_runtime_limits (before function and constructor calls) so embedders can enforce wall-clock deadlines or cancellation flags without adding per-opcode counter overhead to straight-line bytecode.
  • Tracks loop_iteration_count on Vm across the active evaluation (resetting when the outermost frame pops) rather than per CallFrame, so calling a function with a loop from inside another loop cannot reset the counter and bypass RuntimeLimits::loop_iteration_limit.
  • Truncates the entry (exit_early) frame's stack slots and environments when unwinding an uncatchable error in Context::handle_error, matching handle_return and handle_throw so repeated runtime-limit or interrupt aborts do not leak stack slots across evaluations.

@jbingham
jbingham requested a review from a team as a code owner November 6, 2026 17:05
@github-actions github-actions Bot added C-Tests Issues and PRs related to the tests. C-VM Issues and PRs related to the Boa Virtual Machine. Waiting On Review Waiting on reviews from the maintainers labels Nov 6, 2026
@github-actions github-actions Bot added this to the v0.23 milestone Nov 6, 2026
@Triwarr

Triwarr commented Nov 9, 2026 •

Copy link
Copy Markdown

Supporting this PR from an embedder's point of view, with measurements.

Use case. We embed boa_engine 0.22.0 in a desktop/wasm image editor to run user scripts. Dialog event handlers are invoked from Rust via JsFunction::call; on wasm32 they run on the browser main thread, so we need to bound and cancel long-running calls. We pause a script by producing an uncatchable error from a host function: we temporarily set recursion_limit to 0, call an empty function, restore the limits and return that error. We then keep the same Context alive. If this is not a supported way to abort, we would like to know.

1. Per-frame loop counting lets split loops escape loop_iteration_limit (0.22.0):

use boa_engine::{Context, Source};
fn main() {
    let mut ctx = Context::default();
    ctx.runtime_limits_mut().set_loop_iteration_limit(1000);
    let flat = ctx.eval(Source::from_bytes("var n=0; for (var i=0;i<1000000;i++){n++} n"));
    println!("flat 1e6 in one frame: {}", if flat.is_err() {"stopped"} else {"ran"});
    let split = ctx.eval(Source::from_bytes("var n=0; function g(){for(var j=0;j<900;j++){n++}} for (var i=0;i<900;i++){g()} n"));
    println!("900x900 split across frames: {:?}", split.map(|v| v.display().to_string()).map_err(|e| e.to_string()));
}

With a limit of 1,000, the flat loop stops, but the 900x900 split loop runs all 810,000 iterations. Invoked as a callback, the split loop blocked about 31-36 ms in our tests with no way to interrupt it.

2. Repeated uncatchable aborts leak value-stack slots of the entry frame (0.22.0, stack_size_limit = 10240). Call frames are empty afterwards, but the kept Context eventually fails with RuntimeLimitError: StackSize. The leak scales with the number of locals in the function the host called, not with call depth:

locals in entry frame first failing abort
0 1,137
16 410
64 141
256 39

Entry function above: handler(a, b, c) with N extra var locals, pausing directly. With a different entry shape (no parameters, pausing inside 0-64 nested frames below it), nesting depth did not change the threshold (2,559 each time). Both are consistent with the exit_early truncation this PR adds.

On this PR's head (5cfa11469f16428a9549d29be2304cdd5b218b1a), the same 10,000-abort runs stay healthy; the split loop with a 4 ms deadline in check_interrupt stopped at 4,002 µs; and loop_iteration_limit = 1000 now stops the split loop. Tested on Linux x86_64 debug and release; wasm32 compile check only.

Follow-up question (not for this PR): would you consider an API to run a Rust-invoked call under the same instruction budget as budgeted top-level evaluation, so a long callback can yield across ticks instead of only being aborted?

Leak reproduction (0.22.0):

use boa_engine::{Context, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string};
use boa_engine::object::{FunctionObjectBuilder, builtins::JsFunction};
use boa_engine::property::Attribute;
use std::cell::Cell; use std::rc::Rc;

struct S { abort: Cell<Option<JsObject>> }
fn host_pause(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
    let st = ctx.get_data::<Rc<S>>().cloned().unwrap();
    let a = st.abort.take().unwrap();
    let saved = ctx.runtime_limits();
    ctx.runtime_limits_mut().set_recursion_limit(0);
    let r = a.call(&JsValue::undefined(), &[], ctx);
    ctx.set_runtime_limits(saved);
    st.abort.set(Some(a));
    Err(r.unwrap_err())
}
fn mk(locals: u32) -> Context {
    let mut ctx = Context::default();
    ctx.runtime_limits_mut().set_recursion_limit(128);
    ctx.runtime_limits_mut().set_stack_size_limit(10_240);
    let abort = ctx.intrinsics().constructors().function().constructor().construct(&[], None, &mut ctx).unwrap();
    ctx.insert_data(Rc::new(S { abort: Cell::new(Some(abort)) }));
    let p = FunctionObjectBuilder::new(ctx.realm(), NativeFunction::from_fn_ptr(host_pause)).name(js_string!("p")).length(0).build();
    ctx.register_global_property(js_string!("hostPause"), p, Attribute::all()).unwrap();
    let hl:String=(0..locals).map(|i| format!("var v{i}={i};")).collect();
    let src = format!("var healthy=function(){{return 7;}};\
      function handler(a,b,c){{{hl}hostPause();return a;}}");
    ctx.eval(Source::from_bytes(src.as_bytes())).unwrap();
    ctx
}
fn healthy_after(locals: u32, n: u32) -> bool {
    let mut ctx = mk(locals);
    let h = ctx.global_object().get(JsString::from("handler"), &mut ctx).unwrap();
    let f = JsFunction::from_object(h.as_object().unwrap().clone()).unwrap();
    for _ in 0..n { let _ = f.call(&JsValue::undefined(), &[], &mut ctx); }
    matches!(ctx.eval(Source::from_bytes("healthy()")).map(|v| v.as_number()), Ok(Some(7.0)))
}
fn main() {
    for locals in [0u32, 16, 64, 256] {
        let (mut lo, mut hi) = (1u32, 20_000u32);
        if healthy_after(locals, hi) { println!("locals {locals}: healthy after {hi}"); continue; }
        while lo < hi { let m = (lo+hi)/2; if healthy_after(locals, m) { lo = m+1 } else { hi = m } }
        println!("locals {locals}: first failing pauses = {hi}");
    }
}

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

C-Tests Issues and PRs related to the tests. C-VM Issues and PRs related to the Boa Virtual Machine. Waiting On Review Waiting on reviews from the maintainers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants