Skip to main content

hyperlight_js/sandbox/
loaded_js_sandbox.rs

1/*
2Copyright 2026  The Hyperlight Authors.
3
4Licensed under the Apache License, Version 2.0 (the "License");
5you may not use this file except in compliance with the License.
6You may obtain a copy of the License at
7
8    http://www.apache.org/licenses/LICENSE-2.0
9
10Unless required by applicable law or agreed to in writing, software
11distributed under the License is distributed on an "AS IS" BASIS,
12WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13See the License for the specific language governing permissions and
14limitations under the License.
15*/
16use std::fmt::Debug;
17use std::sync::Arc;
18
19use hyperlight_host::hypervisor::InterruptHandle;
20use hyperlight_host::sandbox::snapshot::Snapshot;
21use hyperlight_host::HyperlightError::{self, JsonConversionFailure};
22use hyperlight_host::{MultiUseSandbox, Result};
23use tokio::task::JoinHandle;
24use tracing::{instrument, Level};
25
26#[cfg(feature = "guest-call-stats")]
27use super::execution_stats::ExecutionStats;
28use super::js_sandbox::JSSandbox;
29use super::metrics::{METRIC_SANDBOX_LOADS, METRIC_SANDBOX_UNLOADS};
30use super::monitor::runtime::get_monitor_runtime;
31use super::monitor::MonitorSet;
32#[cfg(feature = "function_call_metrics")]
33use crate::sandbox::metrics::EventHandlerMetricGuard;
34use crate::sandbox::metrics::SandboxMetricsGuard;
35
36/// A Hyperlight Sandbox with a JavaScript run time loaded and guest JavaScript handlers loaded.
37pub struct LoadedJSSandbox {
38    inner: MultiUseSandbox,
39    // Snapshot of state before the sandbox was loaded and before any handlers were added.
40    // This is used to restore state back to a JSSandbox.
41    snapshot: Arc<Snapshot>,
42    // metric drop guard to manage sandbox metric
43    _metric_guard: SandboxMetricsGuard<LoadedJSSandbox>,
44    // Stats from the most recent handle_event / handle_event_with_monitor call.
45    // None before any call has been made.
46    #[cfg(feature = "guest-call-stats")]
47    last_call_stats: Option<ExecutionStats>,
48}
49
50/// RAII guard that aborts a spawned monitor task on drop.
51///
52/// Wraps a tokio `JoinHandle` to ensure the monitor task is cancelled when
53/// the guard goes out of scope — whether that's after normal completion or
54/// on early return. Keeps the spawn-abort lifecycle in one place rather than
55/// requiring manual `abort()` calls at each exit point.
56struct MonitorTask(JoinHandle<()>);
57
58impl Drop for MonitorTask {
59    fn drop(&mut self) {
60        self.0.abort();
61    }
62}
63
64impl LoadedJSSandbox {
65    #[instrument(err(Debug), skip_all, level=Level::INFO)]
66    pub(super) fn new(inner: MultiUseSandbox, snapshot: Arc<Snapshot>) -> Result<LoadedJSSandbox> {
67        metrics::counter!(METRIC_SANDBOX_LOADS).increment(1);
68        Ok(LoadedJSSandbox {
69            inner,
70            snapshot,
71            _metric_guard: SandboxMetricsGuard::new(),
72            #[cfg(feature = "guest-call-stats")]
73            last_call_stats: None,
74        })
75    }
76
77    /// Handles an event by calling the specified function with the event data.
78    #[instrument(err(Debug), skip(self, event, gc), level=Level::INFO)]
79    pub fn handle_event<F>(
80        &mut self,
81        func_name: F,
82        event: String,
83        gc: Option<bool>,
84    ) -> Result<String>
85    where
86        F: Into<String> + std::fmt::Debug,
87    {
88        // check that this string is a valid JSON
89
90        let _json_val: serde_json::Value =
91            serde_json::from_str(&event).map_err(JsonConversionFailure)?;
92
93        let should_gc = gc.unwrap_or(true);
94        let func_name = func_name.into();
95        if func_name.is_empty() {
96            return Err(HyperlightError::Error(
97                "Handler name must not be empty".to_string(),
98            ));
99        }
100
101        #[cfg(feature = "function_call_metrics")]
102        let _metric_guard = EventHandlerMetricGuard::new(&func_name, should_gc);
103
104        // --- guest-call-stats: capture timing before the call ---
105        #[cfg(feature = "guest-call-stats")]
106        let wall_start = std::time::Instant::now();
107
108        #[cfg(all(feature = "guest-call-stats", feature = "monitor-cpu-time"))]
109        let cpu_start = super::monitor::cpu_time::ThreadCpuHandle::for_current_thread()
110            .and_then(|h| h.elapsed().map(|t| (h, t)));
111
112        let result = self.inner.call(&func_name, (event, should_gc));
113
114        // --- guest-call-stats: record timing after the call ---
115        // CPU time is read first so the wall-clock measurement fully wraps it.
116        #[cfg(feature = "guest-call-stats")]
117        {
118            #[cfg(feature = "monitor-cpu-time")]
119            let cpu_time = cpu_start.and_then(|(handle, start_ticks)| {
120                handle.elapsed().map(|end_ticks| {
121                    let delta_nanos =
122                        handle.ticks_to_approx_nanos(end_ticks.saturating_sub(start_ticks));
123                    std::time::Duration::from_nanos(delta_nanos)
124                })
125            });
126            #[cfg(not(feature = "monitor-cpu-time"))]
127            let cpu_time: Option<std::time::Duration> = None;
128
129            let wall_clock = wall_start.elapsed();
130
131            self.last_call_stats = Some(ExecutionStats {
132                wall_clock,
133                cpu_time,
134                terminated_by: None,
135            });
136        }
137
138        result
139    }
140
141    /// Returns the execution statistics from the most recent guest function call.
142    ///
143    /// Returns `None` before any call has been made. After each `handle_event` or
144    /// `handle_event_with_monitor` call, this returns the timing and termination
145    /// information from that call — stats are **not** cumulative.
146    ///
147    /// Stats are captured even when the call returns an error (e.g. the sandbox
148    /// was poisoned by a monitor timeout).
149    ///
150    /// # Example
151    ///
152    /// ```text
153    /// let _ = loaded.handle_event("handler", event, None);
154    /// if let Some(stats) = loaded.last_call_stats() {
155    ///     println!("Wall clock: {:?}", stats.wall_clock);
156    ///     println!("CPU time: {:?}", stats.cpu_time);
157    ///     println!("Terminated by: {:?}", stats.terminated_by);
158    /// }
159    /// ```
160    #[cfg(feature = "guest-call-stats")]
161    pub fn last_call_stats(&self) -> Option<&ExecutionStats> {
162        self.last_call_stats.as_ref()
163    }
164
165    /// Unloads the Handlers from the sandbox and returns a `JSSandbox` with the JavaScript runtime loaded.
166    #[instrument(err(Debug), skip_all, level=Level::DEBUG)]
167    pub fn unload(self) -> Result<JSSandbox> {
168        JSSandbox::from_loaded(self.inner, self.snapshot).inspect(|_| {
169            metrics::counter!(METRIC_SANDBOX_UNLOADS).increment(1);
170        })
171    }
172
173    /// Take a snapshot of the the current state of the sandbox.
174    /// This can be used to restore the state of the sandbox later.
175    #[instrument(err(Debug), skip_all, level=Level::DEBUG)]
176    pub fn snapshot(&mut self) -> Result<Arc<Snapshot>> {
177        self.inner.snapshot()
178    }
179
180    /// Restore the state of the sandbox to a previous snapshot.
181    #[instrument(err(Debug), skip_all, level=Level::DEBUG)]
182    pub fn restore(&mut self, snapshot: Arc<Snapshot>) -> Result<()> {
183        self.inner.restore(snapshot)?;
184        Ok(())
185    }
186
187    /// Get a handle to the interrupt handler for this sandbox,
188    /// capable of interrupting guest execution.
189    pub fn interrupt_handle(&self) -> Arc<dyn InterruptHandle> {
190        self.inner.interrupt_handle()
191    }
192
193    /// Returns whether the sandbox is currently poisoned.
194    ///
195    /// A poisoned sandbox is in an inconsistent state due to the guest not running to completion.
196    /// This can happen when guest execution is interrupted (e.g., via `InterruptHandle::kill()`),
197    /// when the guest panics, or when memory violations occur.
198    ///
199    /// When poisoned, most operations will fail with `PoisonedSandbox` error.
200    /// Use `restore()` with a snapshot or `unload()` to recover from a poisoned state.
201    pub fn poisoned(&self) -> bool {
202        self.inner.poisoned()
203    }
204
205    /// Handles an event with execution monitoring.
206    ///
207    /// The monitor enforces execution limits (time, CPU usage, etc.) and will
208    /// terminate execution if limits are exceeded. If terminated, the sandbox
209    /// will be poisoned and an error is returned.
210    ///
211    /// # Fail-Closed Semantics
212    ///
213    /// If the monitor fails to initialize, the handler is **never executed**.
214    /// Execution cannot proceed unmonitored.
215    ///
216    /// # Tuple Monitors (OR semantics)
217    ///
218    /// Pass a tuple of monitors to enforce multiple limits. The first monitor
219    /// to fire terminates execution, and the winning monitor's name is logged:
220    ///
221    /// ```text
222    /// let monitor = (
223    ///     WallClockMonitor::new(Duration::from_secs(5))?,
224    ///     CpuTimeMonitor::new(Duration::from_millis(500))?,
225    /// );
226    /// loaded.handle_event_with_monitor("handler", "{}".into(), &monitor, None)?;
227    /// ```
228    ///
229    /// # Arguments
230    ///
231    /// * `func_name` - The name of the handler function to call.
232    /// * `event` - JSON string payload to pass to the handler.
233    /// * `monitor` - The execution monitor (or tuple of monitors) to enforce limits.
234    ///   Tuples race all sub-monitors; the first to fire wins and its name is logged.
235    /// * `gc` - Whether to run garbage collection after the call (defaults to `true` if `None`).
236    ///
237    /// # Returns
238    ///
239    /// The handler result string on success, or an error if execution failed
240    /// or was terminated by the monitor. If terminated, the sandbox will be
241    /// poisoned and subsequent calls will fail until restored or unloaded.
242    ///
243    /// # Example
244    ///
245    /// ```text
246    /// use hyperlight_js::WallClockMonitor;
247    /// use std::time::Duration;
248    ///
249    /// let monitor = WallClockMonitor::new(Duration::from_secs(5))?;
250    /// let result = loaded.handle_event_with_monitor(
251    ///     "handler",
252    ///     "{}".to_string(),
253    ///     &monitor,
254    ///     None,
255    /// )?;
256    /// println!("Handler returned: {}", result);
257    /// ```
258    #[instrument(err(Debug), skip(self, event, monitor, gc), level=Level::INFO)]
259    pub fn handle_event_with_monitor<F, M>(
260        &mut self,
261        func_name: F,
262        event: String,
263        monitor: &M,
264        gc: Option<bool>,
265    ) -> Result<String>
266    where
267        F: Into<String> + std::fmt::Debug,
268        M: MonitorSet,
269    {
270        let func_name = func_name.into();
271        if func_name.is_empty() {
272            return Err(HyperlightError::Error(
273                "Handler name must not be empty".to_string(),
274            ));
275        }
276        let interrupt_handle = self.interrupt_handle();
277
278        // Phase 1: Build the racing future on the calling thread.
279        // to_race() calls each sub-monitor's get_monitor() here, where
280        // monitors can capture thread-local state (e.g., CPU clock handles).
281        // If any monitor fails to initialize, we fail closed — handler never runs.
282        let racing_future = monitor.to_race().map_err(|e| {
283            tracing::error!("Failed to initialize execution monitor: {}", e);
284            HyperlightError::Error(format!("Execution monitor failed to start: {}", e))
285        })?;
286
287        // Phase 2: Spawn the racing future on the shared runtime.
288        // When the first monitor fires, to_race() returns the winner's name.
289        // We record the metric/log, store the winner name for stats, then
290        // call kill() to terminate the guest.
291        // kill() is safe to call even if the guest already finished — hyperlight's
292        // InterruptHandle checks RUNNING_BIT and clear_cancel() at the start of
293        // the next guest call clears any stale CANCEL_BIT.
294        let runtime = get_monitor_runtime().ok_or_else(|| {
295            tracing::error!("Monitor runtime is unavailable");
296            HyperlightError::Error("Monitor runtime is unavailable".to_string())
297        })?;
298
299        // Shared slot for the winning monitor's name. The monitor task writes
300        // the winner *before* calling kill(), and handle_event only returns
301        // *after* kill takes effect, so the read after handle_event is safe.
302        let terminated_by = Arc::new(std::sync::Mutex::new(None::<&'static str>));
303        let terminated_by_writer = terminated_by.clone();
304
305        let _monitor_task = MonitorTask(runtime.spawn(async move {
306            let winner = racing_future.await;
307            super::monitor::record_monitor_triggered(winner);
308            // Store the winner name before kill — ordering guarantee:
309            // handle_event returns only after kill() poisons the sandbox,
310            // so the caller sees the write.
311            if let Ok(mut guard) = terminated_by_writer.lock() {
312                *guard = Some(winner);
313            }
314            interrupt_handle.kill();
315        }));
316
317        // Phase 3: Execute the handler (blocking). When this returns (success
318        // or error), _monitor_task drops and aborts the spawned monitor task.
319        let result = self.handle_event(&func_name, event, gc);
320
321        // Phase 4: Patch terminated_by into the stats captured by handle_event.
322        // If the monitor fired, the winner name was written before kill(), so
323        // we can read it safely now.
324        #[cfg(feature = "guest-call-stats")]
325        if let Ok(guard) = terminated_by.lock()
326            && let Some(winner) = *guard
327            && let Some(stats) = &mut self.last_call_stats
328        {
329            stats.terminated_by = Some(winner);
330        }
331
332        result
333    }
334
335    /// Generate a crash dump of the current state of the VM underlying this sandbox.
336    ///
337    /// Creates an ELF core dump file that can be used for debugging. The dump
338    /// captures the current state of the sandbox including registers, memory regions,
339    /// and other execution context.
340    ///
341    /// The location of the core dump file is determined by the `HYPERLIGHT_CORE_DUMP_DIR`
342    /// environment variable. If not set, it defaults to the system's temporary directory.
343    ///
344    /// This is only available when the `crashdump` feature is enabled and then only if the sandbox
345    /// is also configured to allow core dumps (which is the default behavior).
346    ///
347    /// This can be useful for generating a crash dump from gdb when trying to debug issues in the
348    /// guest that dont cause crashes (e.g. a guest function that does not return)
349    ///
350    /// # Examples
351    ///
352    /// Attach to your running process with gdb and call this function:
353    ///
354    /// ```shell
355    /// sudo gdb -p <pid_of_your_process>
356    /// (gdb) info threads
357    /// # find the thread that is running the guest function you want to debug
358    /// (gdb) thread <thread_number>
359    /// # switch to the frame where you have access to your MultiUseSandbox instance
360    /// (gdb) backtrace
361    /// (gdb) frame <frame_number>
362    /// # get the pointer to your MultiUseSandbox instance
363    /// # Get the sandbox pointer
364    /// (gdb) print sandbox
365    /// # Call the crashdump function
366    /// call sandbox.generate_crashdump()
367    /// ```
368    /// The crashdump should be available in crash dump directory (see `HYPERLIGHT_CORE_DUMP_DIR` env var).
369    ///
370    #[cfg(feature = "crashdump")]
371    pub fn generate_crashdump(&self) -> Result<()> {
372        self.inner.generate_crashdump()
373    }
374}
375
376impl Debug for LoadedJSSandbox {
377    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
378        f.debug_struct("LoadedJSSandbox").finish()
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use crate::{SandboxBuilder, Script};
386
387    fn get_valid_handler() -> Script {
388        Script::from_content(
389            r#"
390        function handler(event) {
391            event.request.uri = "/redirected.html";
392            return event
393        }
394        "#,
395        )
396    }
397
398    fn get_valid_event() -> String {
399        r#"
400        {
401            "request": {
402                "uri": "/index.html"
403            }
404        }
405        "#
406        .to_string()
407    }
408
409    fn get_static_counter_handler() -> Script {
410        Script::from_content(
411            r#"
412        let count = 0;
413        function handler(event) {
414            event.count = ++count;
415            return event
416        }
417        "#,
418        )
419    }
420
421    fn get_static_counter_event() -> String {
422        r#"
423        {
424            "count": 0
425        }
426        "#
427        .to_string()
428    }
429
430    fn get_loaded_sandbox() -> Result<LoadedJSSandbox> {
431        let proto_js_sandbox = SandboxBuilder::new().build().unwrap();
432        let mut sandbox = proto_js_sandbox.load_runtime().unwrap();
433
434        sandbox.add_handler("handler", get_valid_handler()).unwrap();
435
436        sandbox.get_loaded_sandbox()
437    }
438
439    #[test]
440    fn test_handle_event() {
441        let proto_js_sandbox = SandboxBuilder::new().build().unwrap();
442        let mut sandbox = proto_js_sandbox.load_runtime().unwrap();
443
444        sandbox.add_handler("handler", get_valid_handler()).unwrap();
445
446        let mut loaded_js_sandbox = sandbox.get_loaded_sandbox().unwrap();
447        let gc = Some(true);
448        let result = loaded_js_sandbox.handle_event("handler".to_string(), get_valid_event(), gc);
449
450        assert!(result.is_ok());
451    }
452
453    #[test]
454    fn test_handle_event_accumulates_state() {
455        let proto_js_sandbox = SandboxBuilder::new().build().unwrap();
456        let mut sandbox = proto_js_sandbox.load_runtime().unwrap();
457        sandbox
458            .add_handler("handler", get_static_counter_handler())
459            .unwrap();
460
461        let mut loaded_js_sandbox = sandbox.get_loaded_sandbox().unwrap();
462        let gc = Some(true);
463        let result = loaded_js_sandbox.handle_event("handler", get_static_counter_event(), gc);
464
465        assert!(result.is_ok());
466        let response = result.unwrap();
467        let response_json: serde_json::Value = serde_json::from_str(&response).unwrap();
468        assert_eq!(response_json["count"], 1);
469
470        let result = loaded_js_sandbox.handle_event("handler", get_static_counter_event(), gc);
471        assert!(result.is_ok());
472        let response = result.unwrap();
473        let response_json: serde_json::Value = serde_json::from_str(&response).unwrap();
474        assert_eq!(response_json["count"], 2);
475    }
476
477    #[test]
478    fn test_snapshot_and_restore() {
479        let proto_js_sandbox = SandboxBuilder::new().build().unwrap();
480        let mut sandbox = proto_js_sandbox.load_runtime().unwrap();
481
482        sandbox
483            .add_handler("handler", get_static_counter_handler())
484            .unwrap();
485
486        let mut loaded_js_sandbox = sandbox.get_loaded_sandbox().unwrap();
487        let gc = Some(true);
488
489        let result = loaded_js_sandbox
490            .handle_event("handler", get_static_counter_event(), gc)
491            .unwrap();
492
493        let response_json: serde_json::Value = serde_json::from_str(&result).unwrap();
494        assert_eq!(response_json["count"], 1);
495
496        // Take a snapshot after handling 1 event
497        let snapshot = loaded_js_sandbox.snapshot().unwrap();
498
499        // Handle 2 more events
500        let result = loaded_js_sandbox
501            .handle_event("handler", get_static_counter_event(), gc)
502            .unwrap();
503        let response_json: serde_json::Value = serde_json::from_str(&result).unwrap();
504        assert_eq!(response_json["count"], 2);
505
506        let result = loaded_js_sandbox
507            .handle_event("handler", get_static_counter_event(), gc)
508            .unwrap();
509        let response_json: serde_json::Value = serde_json::from_str(&result).unwrap();
510        assert_eq!(response_json["count"], 3);
511
512        // Restore the snapshot
513        loaded_js_sandbox.restore(snapshot.clone()).unwrap();
514
515        // Handle the event again, should reset to initial state
516        let result = loaded_js_sandbox
517            .handle_event("handler", get_static_counter_event(), gc)
518            .unwrap();
519        let response_json: serde_json::Value = serde_json::from_str(&result).unwrap();
520        assert_eq!(response_json["count"], 2);
521
522        // unload and reload, and restore
523        let mut js_sandbox = loaded_js_sandbox.unload().unwrap();
524
525        js_sandbox
526            .add_handler("handler2", get_static_counter_handler())
527            .unwrap();
528
529        let mut reloaded_js_sandbox = js_sandbox.get_loaded_sandbox().unwrap();
530
531        // handler2 should be available, not handler
532        let result = reloaded_js_sandbox
533            .handle_event("handler2", get_static_counter_event(), gc)
534            .unwrap();
535        let response_json: serde_json::Value = serde_json::from_str(&result).unwrap();
536        assert_eq!(response_json["count"], 1);
537
538        reloaded_js_sandbox
539            .handle_event("handler", get_static_counter_event(), gc)
540            .unwrap_err();
541
542        // restore to snapshot before unload/reload
543        reloaded_js_sandbox.restore(snapshot.clone()).unwrap();
544        // handler should be available again
545        let result = reloaded_js_sandbox
546            .handle_event("handler", get_static_counter_event(), gc)
547            .unwrap();
548        let response_json: serde_json::Value = serde_json::from_str(&result).unwrap();
549        assert_eq!(response_json["count"], 2);
550
551        // but handler2 should not be available
552        reloaded_js_sandbox
553            .handle_event("handler2", get_static_counter_event(), gc)
554            .unwrap_err();
555    }
556
557    #[test]
558    fn test_add_handler_unload_and_reuse_resets_state() {
559        let proto_js_sandbox = SandboxBuilder::new().build().unwrap();
560        let mut sandbox = proto_js_sandbox.load_runtime().unwrap();
561        sandbox
562            .add_handler("handler", get_static_counter_handler())
563            .unwrap();
564        let mut loaded_js_sandbox = sandbox.get_loaded_sandbox().unwrap();
565        let gc = Some(true);
566
567        let result = loaded_js_sandbox.handle_event("handler", get_static_counter_event(), gc);
568        assert!(result.is_ok());
569        let response = result.unwrap();
570        let response_json: serde_json::Value = serde_json::from_str(&response).unwrap();
571        assert_eq!(response_json["count"], 1);
572
573        let result = loaded_js_sandbox.handle_event("handler", get_static_counter_event(), gc);
574        assert!(result.is_ok());
575        let response = result.unwrap();
576        let response_json: serde_json::Value = serde_json::from_str(&response).unwrap();
577        assert_eq!(response_json["count"], 2);
578
579        // Unload the sandbox
580        let mut sandbox = loaded_js_sandbox.unload().unwrap();
581        sandbox
582            .add_handler("handler", get_static_counter_handler())
583            .unwrap();
584        // Add the handler again
585        let mut loaded_js_sandbox = sandbox.get_loaded_sandbox().unwrap();
586        let gc = Some(true);
587
588        let result = loaded_js_sandbox.handle_event("handler", get_static_counter_event(), gc);
589        assert!(result.is_ok());
590        let response = result.unwrap();
591        let response_json: serde_json::Value = serde_json::from_str(&response).unwrap();
592        assert_eq!(response_json["count"], 1);
593
594        let result = loaded_js_sandbox.handle_event("handler", get_static_counter_event(), gc);
595        assert!(result.is_ok());
596        let response = result.unwrap();
597        let response_json: serde_json::Value = serde_json::from_str(&response).unwrap();
598        assert_eq!(response_json["count"], 2);
599    }
600
601    #[test]
602    fn test_unload() {
603        let sandbox = get_loaded_sandbox().unwrap();
604
605        let result = sandbox.unload();
606
607        assert!(result.is_ok());
608    }
609
610    use crate::sandbox::monitor::ExecutionMonitor;
611
612    /// A mock monitor that always fails to initialize (returns Err).
613    /// Used to test fail-closed behavior.
614    struct FailingMonitor;
615
616    impl ExecutionMonitor for FailingMonitor {
617        fn get_monitor(
618            &self,
619        ) -> hyperlight_host::Result<impl std::future::Future<Output = ()> + Send + 'static>
620        {
621            Err::<std::future::Ready<()>, _>(hyperlight_host::HyperlightError::Error(
622                "Simulated initialization failure".to_string(),
623            ))
624        }
625
626        fn name(&self) -> &'static str {
627            "failing-monitor"
628        }
629    }
630
631    #[test]
632    fn test_handle_event_with_monitor_fails_if_monitor_cannot_start() {
633        let mut loaded = get_loaded_sandbox().unwrap();
634        let monitor = FailingMonitor;
635
636        // Should fail because monitor returns Err (fail closed, not open)
637        let result = loaded.handle_event_with_monitor("handler", get_valid_event(), &monitor, None);
638
639        assert!(result.is_err(), "Should fail when monitor can't start");
640        let err = result.unwrap_err();
641        assert!(
642            err.to_string().contains("failed to start"),
643            "Error should mention monitor failure: {}",
644            err
645        );
646
647        // Sandbox should NOT be poisoned - we never ran the handler
648        assert!(
649            !loaded.poisoned(),
650            "Sandbox should not be poisoned when monitor fails to start"
651        );
652    }
653}