@@ -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
128130impl 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
892961type LoadedJSSandboxGuard = OwnedMappedMutexGuard < Option < LoadedJSSandbox > , LoadedJSSandbox > ;
893962
894963impl 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 }
0 commit comments