feat(engine): HostHooks::on_tick for interrupting sync interpreter#5380
Draft
shregar1 wants to merge 1 commit into
Draft
feat(engine): HostHooks::on_tick for interrupting sync interpreter#5380shregar1 wants to merge 1 commit into
shregar1 wants to merge 1 commit into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a periodic
on_tick(&self) -> JsResult<()>callback to theHostHookstrait, invoked every ~1024 bytecode instructions insideVm::run. ReturningErrterminates 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 existingcall_job_callbackhook 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 behaviorVm::rundecrements au32counter every instruction; when it hitsTICK_INTERVAL(1024) it resets and callson_tickErr, that error becomes the script'sCompletionRecord::ThrowOverhead
One
u32increment + one compare per bytecode instruction. The interval is a compile-time constant; LLVM should hoist the constant compare cheaply. WithTICK_INTERVAL = 1024the 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_tickget 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
main(notreleases/0.21) so the patch lands against currentVm::run. The 0.21 release has structurally the same loop; happy to backport if useful.TICK_INTERVALknob on the builder — wanted to keep the surface minimal for review. If you'd prefer it configurable, I can wire it throughContextBuilder.on_tickand verifieswhile(true){}is killable if you'd like one before merge.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com