Skip to content

Commit ceea3af

Browse files
simongdaviesCopilot
andcommitted
feat: expose host print function to Node.js API
Add setHostPrintFn() to SandboxBuilder allowing Node.js callers to receive guest console.log/print output via a callback. - setHostPrintFn(callback) on SandboxBuilder (NAPI layer) - Uses ThreadsafeFunction<String> in Blocking mode - Callback invoked synchronously in try/catch (no microtask deferral) - TypeError thrown for non-function arguments - Host function TSFN calls switched to Blocking mode for consistency - Runtime deadlock detection (ERR_REENTRANT) when host callbacks attempt to call back into the sandbox while guest code is executing - Vitest coverage for chaining, log delivery, error handling, consumed state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
1 parent 80f4b55 commit ceea3af

3 files changed

Lines changed: 249 additions & 14 deletions

File tree

src/js-host-api/lib.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,30 @@ for (const method of [
208208
SandboxBuilder.prototype[method] = wrapSync(orig);
209209
}
210210

211+
// setHostPrintFn needs a custom wrapper: the user's callback is wrapped in
212+
// try/catch before it reaches the native layer, because exceptions thrown
213+
// inside a Blocking ThreadsafeFunction escape as unhandled errors.
214+
{
215+
const origSetHostPrintFn = SandboxBuilder.prototype.setHostPrintFn;
216+
if (!origSetHostPrintFn) {
217+
throw new Error('Cannot wrap missing method: SandboxBuilder.setHostPrintFn');
218+
}
219+
SandboxBuilder.prototype.setHostPrintFn = wrapSync(function (callback) {
220+
if (typeof callback !== 'function') {
221+
throw new TypeError(
222+
`SandboxBuilder.setHostPrintFn expects a function, received ${typeof callback}`
223+
);
224+
}
225+
return origSetHostPrintFn.call(this, (msg) => {
226+
try {
227+
callback(msg);
228+
} catch (e) {
229+
console.error('Host print callback threw:', e);
230+
}
231+
});
232+
});
233+
}
234+
211235
// ── Re-export ────────────────────────────────────────────────────────
212236

213237
module.exports = native;

src/js-host-api/src/lib.rs

Lines changed: 113 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ enum ErrorCode {
123123
Consumed,
124124
/// Internal / unexpected failure (lock poison, task join error, etc.).
125125
Internal,
126+
/// Reentrant call detected — calling sandbox methods from a host callback.
127+
Reentrant,
126128
}
127129

128130
impl ErrorCode {
@@ -135,6 +137,7 @@ impl ErrorCode {
135137
Self::InvalidArg => "ERR_INVALID_ARG",
136138
Self::Consumed => "ERR_CONSUMED",
137139
Self::Internal => "ERR_INTERNAL",
140+
Self::Reentrant => "ERR_REENTRANT",
138141
}
139142
}
140143
}
@@ -230,6 +233,15 @@ fn join_error(err: tokio::task::JoinError) -> napi::Error {
230233
)
231234
}
232235

236+
/// Creates an error for reentrant calls detected at runtime.
237+
fn reentrant_error() -> napi::Error {
238+
hl_error(
239+
ErrorCode::Reentrant,
240+
"Cannot call sandbox methods from a host callback — the sandbox lock is held during \
241+
guest execution. Perform this operation after callHandler() resolves instead",
242+
)
243+
}
244+
233245
// ── Snapshot ─────────────────────────────────────────────────────────
234246

235247
/// A captured point-in-time state of a sandbox.
@@ -401,6 +413,55 @@ impl SandboxBuilderWrapper {
401413
inner: Arc::new(Mutex::new(Some(proto_sandbox))),
402414
})
403415
}
416+
417+
/// Set a callback that receives guest `console.log` / `print` output.
418+
///
419+
/// Without this, guest print output is silently discarded. The callback
420+
/// receives each print message as a string.
421+
///
422+
/// If the callback throws, the exception is caught by the JS wrapper
423+
/// (`lib.js`) and logged to `console.error`. Guest execution continues.
424+
///
425+
/// @param callback - `(message: string) => void` — called for each print
426+
/// @returns this (for chaining)
427+
/// @throws If the builder has already been consumed by `build()`
428+
#[napi]
429+
pub fn set_host_print_fn(
430+
&self,
431+
#[napi(ts_arg_type = "(message: string) => void")] callback: ThreadsafeFunction<
432+
String, // Rust → JS argument type
433+
(), // JS return type (void)
434+
String, // JS → Rust argument type (same — identity mapping)
435+
Status, // Error status type
436+
false, // Not CallerHandled (napi manages errors)
437+
false, // Not accepting unknown return types
438+
>,
439+
) -> napi::Result<&Self> {
440+
self.with_inner(|b| {
441+
// Blocking mode ensures the TSFN call is queued even when the
442+
// queue is full (it blocks until space is available), preventing
443+
// silent message drops that NonBlocking mode would cause.
444+
//
445+
// The JS wrapper invokes the user callback synchronously in the
446+
// TSFN handler — no microtask deferral.
447+
//
448+
// **Reentrancy note:** The print callback runs while the sandbox
449+
// Mutex is held (inside `call_handler`'s `spawn_blocking`).
450+
// If user code in the callback attempts to call methods that
451+
// acquire the same lock (e.g. `snapshot()`, `restore()`,
452+
// `unload()`, `callHandler()`), the `executing_flag` deadlock
453+
// detection will return `ERR_REENTRANT` instead of hanging.
454+
let print_fn = move |msg: String| -> i32 {
455+
let status = callback.call(msg, ThreadsafeFunctionCallMode::Blocking);
456+
if status == Status::Ok {
457+
0
458+
} else {
459+
-1
460+
}
461+
};
462+
b.with_host_print_fn(print_fn.into())
463+
})
464+
}
404465
}
405466

406467
// ── ProtoJSSandbox ───────────────────────────────────────────────────
@@ -635,10 +696,10 @@ impl HostModuleWrapper {
635696
return Err(invalid_arg_error("Function name must not be empty"));
636697
}
637698
let wrapper = move |args: String| -> hyperlight_js::Result<String> {
638-
use ThreadsafeFunctionCallMode::NonBlocking;
699+
use ThreadsafeFunctionCallMode::Blocking;
639700
let args: Vec<Option<serde_json::Value>> = serde_json::from_str(&args)?;
640701
let (tx, rx) = oneshot::channel();
641-
let status = func.call_with_return_value(Rest(args), NonBlocking, move |result, _| {
702+
let status = func.call_with_return_value(Rest(args), Blocking, move |result, _| {
642703
let _ = tx.send(result);
643704
Ok(())
644705
});
@@ -805,6 +866,7 @@ impl JSSandboxWrapper {
805866
poisoned_flag,
806867
last_call_stats: Arc::new(ArcSwapOption::empty()),
807868
disposed_flag: Arc::new(AtomicBool::new(false)),
869+
executing_flag: Arc::new(AtomicBool::new(false)),
808870
})
809871
}
810872

@@ -887,17 +949,37 @@ pub struct LoadedJSSandboxWrapper {
887949
/// `unload()`), for lock-free checks in sync getters that don't
888950
/// consult the inner Mutex.
889951
disposed_flag: Arc<AtomicBool>,
952+
953+
/// Tracks whether guest code is currently executing inside `call_handler`.
954+
///
955+
/// Used for deadlock detection: if a host callback (print fn, host function)
956+
/// tries to call a method that needs the inner Mutex while this is `true`,
957+
/// we return `ERR_REENTRANT` immediately instead of deadlocking.
958+
executing_flag: Arc<AtomicBool>,
890959
}
891960

892961
type LoadedJSSandboxGuard = OwnedMappedMutexGuard<Option<LoadedJSSandbox>, LoadedJSSandbox>;
893962

894963
impl LoadedJSSandboxWrapper {
895964
/// Borrow the inner value mutably via Mutex, or error if consumed.
965+
///
966+
/// Performs deadlock detection: if `executing_flag` is set (meaning we're
967+
/// inside a `call_handler` on a background thread), `try_lock` is attempted
968+
/// first. If it fails, we know this is a reentrant call from a host callback
969+
/// and return `ERR_REENTRANT` immediately instead of deadlocking.
896970
async fn with_inner<R>(
897971
&self,
898972
f: impl AsyncFnOnce(LoadedJSSandboxGuard) -> napi::Result<R>,
899973
) -> napi::Result<R> {
900-
let sandbox = self.inner.clone().lock_owned().await;
974+
let sandbox = if self.executing_flag.load(Ordering::Acquire) {
975+
// We're inside a host callback — try_lock to detect reentrancy.
976+
match self.inner.clone().try_lock_owned() {
977+
Ok(guard) => guard,
978+
Err(_) => return Err(reentrant_error()),
979+
}
980+
} else {
981+
self.inner.clone().lock_owned().await
982+
};
901983
let sandbox = OwnedMutexGuard::try_map(sandbox, Option::as_mut)
902984
.map_err(|_| consumed_error("LoadedJSSandbox"))?;
903985
f(sandbox).await
@@ -906,30 +988,42 @@ impl LoadedJSSandboxWrapper {
906988
/// Borrow the inner value mutably via Mutex, or error if consumed.
907989
/// The closure `f` will run using spawn_blocking, so it can perform long-running operations without
908990
/// blocking the Node.js event loop. This is the main way to interact with the inner `LoadedJSSandbox`.
991+
///
992+
/// Sets `executing_flag` for the duration of the blocking closure so that
993+
/// reentrant calls from host callbacks are detected instead of deadlocking.
909994
async fn with_blocking_inner<R: Send + 'static>(
910995
&self,
911996
f: impl FnOnce(LoadedJSSandboxGuard) -> napi::Result<R> + Send + 'static,
912997
) -> napi::Result<R> {
998+
let executing_flag = self.executing_flag.clone();
913999
self.with_inner(async move |sandbox| {
914-
tokio::task::spawn_blocking(move || f(sandbox))
1000+
executing_flag.store(true, Ordering::Release);
1001+
let result = tokio::task::spawn_blocking(move || f(sandbox))
9151002
.await
916-
.map_err(join_error)?
1003+
.map_err(join_error)?;
1004+
executing_flag.store(false, Ordering::Release);
1005+
result
9171006
})
9181007
.await
9191008
}
9201009

9211010
/// Take ownership of the inner value, returning a consumed-state error if
9221011
/// this instance has already been used.
1012+
///
1013+
/// Performs the same deadlock detection as `with_inner`.
9231014
async fn take_inner_with<R>(
9241015
&self,
9251016
f: impl AsyncFnOnce(LoadedJSSandbox) -> napi::Result<R>,
9261017
) -> napi::Result<R> {
927-
let sandbox = self
928-
.inner
929-
.lock()
930-
.await
931-
.take()
932-
.ok_or_else(|| consumed_error("LoadedJSSandbox"))?;
1018+
let sandbox = if self.executing_flag.load(Ordering::Acquire) {
1019+
match self.inner.try_lock() {
1020+
Ok(mut guard) => guard.take(),
1021+
Err(_) => return Err(reentrant_error()),
1022+
}
1023+
} else {
1024+
self.inner.lock().await.take()
1025+
}
1026+
.ok_or_else(|| consumed_error("LoadedJSSandbox"))?;
9331027
self.disposed_flag.store(true, Ordering::Release);
9341028
f(sandbox).await
9351029
}
@@ -938,14 +1032,20 @@ impl LoadedJSSandboxWrapper {
9381032
/// this instance has already been used.
9391033
/// The closure `f` will run using spawn_blocking, so it can perform long-running operations without
9401034
/// blocking the Node.js event loop. This is the main way to interact with the inner `LoadedJSSandbox`.
1035+
///
1036+
/// Sets `executing_flag` for the duration of the blocking closure.
9411037
async fn take_blocking_inner_with<R: Send + 'static>(
9421038
&self,
9431039
f: impl FnOnce(LoadedJSSandbox) -> napi::Result<R> + Send + 'static,
9441040
) -> napi::Result<R> {
1041+
let executing_flag = self.executing_flag.clone();
9451042
self.take_inner_with(async move |sandbox| {
946-
tokio::task::spawn_blocking(move || f(sandbox))
1043+
executing_flag.store(true, Ordering::Release);
1044+
let result = tokio::task::spawn_blocking(move || f(sandbox))
9471045
.await
948-
.map_err(join_error)?
1046+
.map_err(join_error)?;
1047+
executing_flag.store(false, Ordering::Release);
1048+
result
9491049
})
9501050
.await
9511051
}

src/js-host-api/tests/sandbox.test.js

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Basic sandbox functionality tests
2-
import { describe, it, expect, beforeEach } from 'vitest';
2+
import { describe, it, expect, beforeEach, vi } from 'vitest';
33
import { SandboxBuilder } from '../lib.js';
44
import { expectThrowsWithCode, expectRejectsWithCode } from './test-helpers.js';
55

@@ -445,3 +445,114 @@ describe('LoadedJSSandbox.unload()', () => {
445445
expectThrowsWithCode(() => loaded.lastCallStats, 'ERR_CONSUMED');
446446
});
447447
});
448+
449+
// ── Host print function ──────────────────────────────────────────────
450+
451+
describe('setHostPrintFn', () => {
452+
it('should support method chaining', () => {
453+
const builder = new SandboxBuilder();
454+
const returned = builder.setHostPrintFn(() => {});
455+
expect(returned).toBe(builder);
456+
});
457+
458+
it('should receive console.log output from the guest', async () => {
459+
const messages = [];
460+
const builder = new SandboxBuilder().setHostPrintFn((msg) => {
461+
messages.push(msg);
462+
});
463+
const proto = await builder.build();
464+
const sandbox = await proto.loadRuntime();
465+
sandbox.addHandler(
466+
'handler',
467+
`function handler(event) {
468+
console.log("Hello from guest!");
469+
return event;
470+
}`
471+
);
472+
const loaded = await sandbox.getLoadedSandbox();
473+
await loaded.callHandler('handler', {});
474+
475+
expect(messages.join('')).toContain('Hello from guest!');
476+
});
477+
478+
it('should receive multiple console.log calls', async () => {
479+
const messages = [];
480+
const builder = new SandboxBuilder().setHostPrintFn((msg) => {
481+
messages.push(msg);
482+
});
483+
const proto = await builder.build();
484+
const sandbox = await proto.loadRuntime();
485+
sandbox.addHandler(
486+
'handler',
487+
`function handler(event) {
488+
console.log("first");
489+
console.log("second");
490+
console.log("third");
491+
return event;
492+
}`
493+
);
494+
const loaded = await sandbox.getLoadedSandbox();
495+
await loaded.callHandler('handler', {});
496+
497+
const combined = messages.join('');
498+
expect(combined).toContain('first');
499+
expect(combined).toContain('second');
500+
expect(combined).toContain('third');
501+
});
502+
503+
it('should use the last callback when set multiple times', async () => {
504+
const firstMessages = [];
505+
const secondMessages = [];
506+
const builder = new SandboxBuilder()
507+
.setHostPrintFn((msg) => firstMessages.push(msg))
508+
.setHostPrintFn((msg) => secondMessages.push(msg));
509+
const proto = await builder.build();
510+
const sandbox = await proto.loadRuntime();
511+
sandbox.addHandler(
512+
'handler',
513+
`function handler(event) {
514+
console.log("which callback?");
515+
return event;
516+
}`
517+
);
518+
const loaded = await sandbox.getLoadedSandbox();
519+
await loaded.callHandler('handler', {});
520+
521+
expect(firstMessages.length).toBe(0);
522+
expect(secondMessages.join('')).toContain('which callback?');
523+
});
524+
525+
it('should continue guest execution when callback throws', async () => {
526+
const builder = new SandboxBuilder().setHostPrintFn(() => {
527+
throw new Error('print callback exploded');
528+
});
529+
const proto = await builder.build();
530+
const sandbox = await proto.loadRuntime();
531+
sandbox.addHandler(
532+
'handler',
533+
`function handler(event) {
534+
console.log("this will throw in the callback");
535+
return { survived: true };
536+
}`
537+
);
538+
const loaded = await sandbox.getLoadedSandbox();
539+
540+
// Spy on console.error to suppress noise and verify the error is logged
541+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
542+
try {
543+
const result = await loaded.callHandler('handler', {});
544+
545+
// The JS wrapper catches the throw — guest continues normally
546+
expect(result.survived).toBe(true);
547+
expect(errorSpy).toHaveBeenCalledWith('Host print callback threw:', expect.any(Error));
548+
} finally {
549+
errorSpy.mockRestore();
550+
}
551+
});
552+
553+
it('should throw CONSUMED after build()', async () => {
554+
const builder = new SandboxBuilder();
555+
await builder.build();
556+
expectThrowsWithCode(() => builder.setHostPrintFn(() => {}), 'ERR_CONSUMED');
557+
});
558+
});

0 commit comments

Comments
 (0)