hyperlight-dev / hyperlight-dev/hyperlight-js

Host function callbacks can deadlock when calling back into the sandbox

Open
#192 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug lifecycle/needs-review
Dominant language
Rust
Stars
13
Forks
5
Avg merge
8d 10h
Merged PRs (30d)
13

Description

Problem

When a host function callback (registered via registerHostFunction or setHostPrintFn) tries to call back into the same sandbox (e.g. callHandler, snapshot, restore, unload), it deadlocks.

This happens because call_handler holds the LoadedJSSandbox mutex for the entire duration of guest execution. Host functions are dispatched via TSFN to the Node.js main thread while that lock is held. If the callback then calls any method that needs the same lock, it waits forever.

Why this doesn't happen in core hyperlight

In hyperlight-dev/hyperlight, the host function registry (Arc<Mutex<FunctionRegistry>>) uses a separate lock from the sandbox. Host functions are dispatched synchronously while the VM is paused — they don't need the sandbox lock at all. See src/hyperlight_host/src/sandbox/outb.rs.

In hyperlight-js, the QuickJS runtime invokes host function closures inside handle_event, which requires &mut self on the sandbox. The NAPI layer wraps this in a single tokio::sync::Mutex, so host function dispatch and sandbox lifecycle share the same lock.

Current workaround

PR #55 adds an executing_flag (AtomicBool) that detects reentrancy at runtime. If a callback tries to acquire the lock while guest code is executing, it returns ERR_REENTRANT instead of deadlocking. This prevents hangs but doesn't allow the operation to succeed.

Suggested fix

Separate host function dispatch from the sandbox lock, similar to how core hyperlight does it. Options:

  1. Move host function state out of the &mut self borrow so callbacks don't need the sandbox lock
  2. Temporarily release the sandbox lock before dispatching to host functions, reacquire after
  3. Provide a shared FFI/binding helper crate that handles this pattern correctly for any language binding

Reproduction

const loaded = await sandbox.getLoadedSandbox();

proto.registerHostModule('mymod', (mod) => {
  mod.registerHostFunction('callback', async () => {
    // This deadlocks (or returns ERR_REENTRANT with the fix)
    await loaded.callHandler('other_handler', {});
    return 'result';
  });
});

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by tracing the sandbox lock through call_handler and host-function dispatch in handle_event, then compare the separate-lock approach in src/hyperlight_host/src/sandbox/outb.rs. Reproduce the callback using registerHostFunction and callHandler, and review PR #55's executing_flag. Done means callbacks can call sandbox operations without deadlocking, with coverage for the reported reproduction.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, rust
Domain
backend
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.