Skip to content

feat(engine): HostHooks::on_tick for interrupting sync interpreter#5380

Draft
shregar1 wants to merge 1 commit into
boa-dev:mainfrom
shregar1:vexjs/host-hooks-on-tick
Draft

feat(engine): HostHooks::on_tick for interrupting sync interpreter#5380
shregar1 wants to merge 1 commit into
boa-dev:mainfrom
shregar1:vexjs/host-hooks-on-tick

Conversation

@shregar1
Copy link
Copy Markdown

Summary

Adds a periodic on_tick(&self) -> JsResult<()> callback to the HostHooks trait, invoked every ~1024 bytecode instructions inside Vm::run. Returning Err terminates execution with that error.

This gives embedders a way to interrupt synchronous JS — while(true){} and similar pure-JS loops that currently have no interruption point. The existing call_job_callback hook only fires at promise/microtask boundaries, so it can't catch a script that never yields.

Motivation

Building a sandboxed JS runtime on top of Boa, I hit the well-known limitation that pure-JS infinite loops can't be terminated by the embedder. The escape hatches (separate thread + timeout, custom JobQueue, etc.) all have downsides: extra threads and lifetime juggling, or only working for promise-heavy code.

A per-instruction host hook is the lowest-overhead general solution. The patch is 29 lines.

Design

  • HostHooks::on_tick(&self) -> JsResult<()> with a no-op default impl — no existing consumer changes behavior
  • Vm::run decrements a u32 counter every instruction; when it hits TICK_INTERVAL (1024) it resets and calls on_tick
  • If the hook returns Err, that error becomes the script's CompletionRecord::Throw

Overhead

One u32 increment + one compare per bytecode instruction. The interval is a compile-time constant; LLVM should hoist the constant compare cheaply. With TICK_INTERVAL = 1024 the worst-case latency after a deadline expires is roughly N microseconds for N being the per-instruction time × 1024 — well under a millisecond on modern hardware.

Why not a feature flag?

The default impl is a no-op so there's nothing to gate. Embedders who don't implement on_tick get exactly the previous behavior. If the increment+compare overhead is a measurable concern in some benchmark I'm not aware of, happy to put it behind a feature.

Use case

```rust
struct DeadlineHooks { deadline: Instant }

impl HostHooks for DeadlineHooks {
fn on_tick(&self) -> JsResult<()> {
if Instant::now() > self.deadline {
Err(JsNativeError::error()
.with_message("deadline exceeded")
.into())
} else {
Ok(())
}
}
}
```

In my downstream this closes the last "but you can hang the interpreter" gap in a default-deny capability sandbox.

Notes

  • Branched off main (not releases/0.21) so the patch lands against current Vm::run. The 0.21 release has structurally the same loop; happy to backport if useful.
  • I didn't add a TICK_INTERVAL knob on the builder — wanted to keep the surface minimal for review. If you'd prefer it configurable, I can wire it through ContextBuilder.
  • Tests: I've been running the patched copy in my downstream for the use case above; happy to add a Boa-level test that overrides on_tick and verifies while(true){} is killable if you'd like one before merge.

Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

Adds a periodic callback to the HostHooks trait that fires every
TICK_INTERVAL (1024) bytecode instructions during synchronous
interpretation. Returning Err terminates execution with the provided
JsError.

This closes a gap embedders hit when running untrusted scripts or
just enforcing wall-clock budgets: pure-JS loops like `while(true){}`
currently have no interruption point — the existing microtask
boundary check via call_job_callback only fires for promise jobs.

The default impl is a no-op so no existing HostHooks consumer changes
behavior. Per-instruction overhead is one u32 increment + compare;
TICK_INTERVAL=1024 keeps that negligible while bounding the
worst-case "still running after Err" window to microseconds on
modern hardware.

Use case (the embedder this came out of):

    impl HostHooks for MyHooks {
        fn on_tick(&self) -> JsResult<()> {
            if Instant::now() > self.deadline {
                Err(JsNativeError::error().with_message("deadline exceeded").into())
            } else {
                Ok(())
            }
        }
    }

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added C-VM Issues and PRs related to the Boa Virtual Machine. Waiting On Review Waiting on reviews from the maintainers labels May 21, 2026
@github-actions github-actions Bot added this to the v1.0.0 milestone May 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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.

2 participants