From 0f154a58f1683a5888100d319a1d315d7b034b90 Mon Sep 17 00:00:00 2001 From: danbugs Date: Tue, 19 May 2026 21:38:18 +0000 Subject: [PATCH 1/7] feat: add run_code_with_timeout to pyhl::Runtime Signed-off-by: danbugs --- host/src/lib.rs | 6 ++++ host/src/pyhl.rs | 62 ++++++++++++++++++++++++++++++++++++++ host/tests/pyhl_runtime.rs | 52 ++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/host/src/lib.rs b/host/src/lib.rs index 6456587..6307008 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -2299,6 +2299,12 @@ impl Sandbox { self.exit_code.store(0, Ordering::Relaxed); } + /// Obtain a handle that can interrupt a running guest call from + /// another thread. See [`hyperlight_host::hypervisor::InterruptHandle`]. + pub fn interrupt_handle(&self) -> Arc { + self.inner.interrupt_handle() + } + /// Take a new snapshot of the current guest state. /// /// Useful for the "snapshot after one-time warm-up" pattern: call diff --git a/host/src/pyhl.rs b/host/src/pyhl.rs index 8f2d02b..7f302cb 100644 --- a/host/src/pyhl.rs +++ b/host/src/pyhl.rs @@ -44,6 +44,7 @@ use anyhow::{anyhow, bail, Context, Result}; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::time::Instant; use crate::{Preopen, Sandbox}; @@ -312,6 +313,67 @@ impl Runtime { Ok(t) } + /// Like [`run_code`](Self::run_code), but kills the guest if it + /// exceeds `timeout`. Returns an error when the guest is + /// interrupted. The runtime is left in a usable state — the next + /// `run_code*` call will restore from the snapshot automatically. + pub fn run_code_with_timeout( + &mut self, + code: &str, + timeout: std::time::Duration, + ) -> Result { + let mut t = RunTiming::default(); + if !self.first_run { + let tr = Instant::now(); + self.sandbox.restore()?; + t.restore_ms = tr.elapsed().as_secs_f64() * 1000.0; + } + self.first_run = false; + self.sandbox.reset_exit_code(); + + let handle = self.sandbox.interrupt_handle(); + let done = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let done_clone = done.clone(); + + let timer = std::thread::spawn(move || { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if done_clone.load(std::sync::atomic::Ordering::Relaxed) { + return false; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + if !done_clone.load(std::sync::atomic::Ordering::Relaxed) { + handle.kill(); + return true; + } + false + }); + + let tc = Instant::now(); + let call_result: Result<()> = self.sandbox.call_named("run", code.to_string()); + t.call_ms = tc.elapsed().as_secs_f64() * 1000.0; + + done.store(true, std::sync::atomic::Ordering::Relaxed); + let timed_out = timer.join().unwrap_or(false); + + match call_result { + Ok(()) => { + t.exit_code = self.sandbox.last_exit_code(); + Ok(t) + } + Err(_) if timed_out => { + // Restore so the next call starts clean. + self.sandbox.restore()?; + Err(anyhow!( + "execution timed out after {:.1}s", + timeout.as_secs_f64() + )) + } + Err(e) => Err(e), + } + } + /// Convenience: read a file and run its contents. pub fn run_script(&mut self, path: &Path) -> Result { let code = diff --git a/host/tests/pyhl_runtime.rs b/host/tests/pyhl_runtime.rs index 3e46886..ad9ba85 100644 --- a/host/tests/pyhl_runtime.rs +++ b/host/tests/pyhl_runtime.rs @@ -339,6 +339,58 @@ fn runtime_network_disabled_by_default() { ); } +// --------------------------------------------------------------------------- +// Timeout enforcement +// --------------------------------------------------------------------------- + +#[test] +fn runtime_timeout_kills_long_running_code() { + let Some((_home, mut rt)) = setup() else { + return; + }; + let result = rt.run_code_with_timeout( + "import time; time.sleep(120); print('should not reach here')", + std::time::Duration::from_secs(2), + ); + assert!(result.is_err(), "long-running code should be killed"); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("timed out"), + "error should mention timeout, got: {err}" + ); +} + +#[test] +fn runtime_timeout_allows_fast_code() { + let Some((_home, mut rt)) = setup() else { + return; + }; + let timing = rt + .run_code_with_timeout( + "print('fast')", + std::time::Duration::from_secs(10), + ) + .unwrap(); + assert_eq!(timing.exit_code, 0); +} + +#[test] +fn runtime_usable_after_timeout() { + let Some((_home, mut rt)) = setup() else { + return; + }; + // First call: times out + let result = rt.run_code_with_timeout( + "import time; time.sleep(120)", + std::time::Duration::from_secs(2), + ); + assert!(result.is_err()); + + // Second call: should work normally + let timing = rt.run_code("print('recovered')").unwrap(); + assert_eq!(timing.exit_code, 0); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- From 47357af34ad3add62ad3f233310e0136ef27ac58 Mon Sep 17 00:00:00 2001 From: danbugs Date: Tue, 19 May 2026 22:13:00 +0000 Subject: [PATCH 2/7] fix: make __hl_sleep cancellable via condvar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit std::thread::sleep() restarts on EINTR, so interrupt_handle.kill() could never wake a sleeping guest — the host function blocked for the full duration (up to 60s cap). Replace with a Condvar-based sleep that can be woken immediately by SleepCancel::cancel(). Signed-off-by: danbugs --- host/src/lib.rs | 119 +++++++++++++++++++++++++++++++++++++++++------ host/src/pyhl.rs | 3 ++ 2 files changed, 109 insertions(+), 13 deletions(-) diff --git a/host/src/lib.rs b/host/src/lib.rs index 6307008..9fcee98 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -68,7 +68,7 @@ use std::collections::{HashMap, HashSet}; use std::net::IpAddr; use std::path::Path; use std::sync::atomic::{AtomicI32, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Condvar, Mutex}; use std::time::Duration; /// Magic header for cmdline embedded in initrd: "HLCMDLN\0" @@ -108,6 +108,40 @@ const MAX_DISPATCH_PAYLOAD: usize = 64 * 1024 * 1024; /// Cap for `__hl_sleep` duration to prevent unbounded host-thread blocking (60 s). const MAX_SLEEP_NS: u64 = 60_000_000_000; +/// Shared cancellation primitive for `__hl_sleep`. Calling +/// [`SleepCancel::cancel`] wakes up any in-progress sleep immediately so +/// the host function returns and the execution loop can check +/// `is_cancelled()`. +#[derive(Clone)] +pub struct SleepCancel(Arc<(Mutex, Condvar)>); + +impl SleepCancel { + fn new() -> Self { + Self(Arc::new((Mutex::new(false), Condvar::new()))) + } + + /// Wake any in-progress `__hl_sleep` immediately. + pub fn cancel(&self) { + let (lock, cvar) = &*self.0; + *lock.lock().unwrap() = true; + cvar.notify_all(); + } + + /// Reset so the next guest call can sleep normally. + pub fn reset(&self) { + *self.0 .0.lock().unwrap() = false; + } + + fn wait(&self, dur: Duration) { + let (lock, cvar) = &*self.0; + let guard = lock.lock().unwrap(); + if *guard { + return; + } + let _ = cvar.wait_timeout(guard, dur); + } +} + /// Cap for `fs_list` directory entries to prevent host OOM on huge directories. const MAX_DIR_ENTRIES: usize = 100_000; @@ -957,6 +991,7 @@ fn build_tools( fn register_internal_tools( tools: &mut ToolRegistry, exit_code: &Arc, + sleep_cancel: &SleepCancel, network: Option<&NetworkPolicy>, listen_ports: Option<&ListenPorts>, ) -> Option>> { @@ -966,10 +1001,11 @@ fn register_internal_tools( ec.store(code, Ordering::Relaxed); Ok(serde_json::json!({})) }); - tools.register("__hl_sleep", |args| { + let sc = sleep_cancel.clone(); + tools.register("__hl_sleep", move |args| { let ns = args["ns"].as_u64().unwrap_or(0).min(MAX_SLEEP_NS); if ns > 0 { - std::thread::sleep(std::time::Duration::from_nanos(ns)); + sc.wait(Duration::from_nanos(ns)); } Ok(serde_json::json!({})) }); @@ -982,7 +1018,6 @@ fn register_internal_tools( use socket2::{Domain, Protocol, SockAddr, Socket, Type}; use std::net::SocketAddr; -use std::sync::Mutex; struct HostSocket { socket: Socket, @@ -1940,6 +1975,8 @@ pub struct Sandbox { /// Shared socket table — cleared on [`Sandbox::restore`] so that /// host-side fds don't leak across guest restore cycles. socket_table: Option>>, + /// Cancellation token for in-progress `__hl_sleep` host calls. + sleep_cancel: SleepCancel, } /// Where the initrd comes from — either a file (zero-copy `map_file_cow`) @@ -2148,15 +2185,17 @@ impl Sandbox { let mut usbox = UninitializedSandbox::new(env, Some(config.sandbox_config()))?; let exit_code = Arc::new(AtomicI32::new(0)); + let sleep_cancel = SleepCancel::new(); let mut tools = build_tools(tools, preopens)?.unwrap_or_default(); - let socket_table = register_internal_tools(&mut tools, &exit_code, network, listen_ports); + let socket_table = + register_internal_tools(&mut tools, &exit_code, &sleep_cancel, network, listen_ports); let tools = Arc::new(tools); let tools_ref = tools.clone(); usbox.register_host_function("__dispatch", move |payload: Vec| -> Vec { tools_ref.dispatch(&payload) })?; - Self::finish_evolve(usbox, None, 0, exit_code, socket_table) + Self::finish_evolve(usbox, None, 0, exit_code, sleep_cancel, socket_table) } /// Low-level: boot with a zero-copy mapped initrd file. Prefer the builder. @@ -2200,8 +2239,10 @@ impl Sandbox { } let exit_code = Arc::new(AtomicI32::new(0)); + let sleep_cancel = SleepCancel::new(); let mut tools = build_tools(tools, preopens)?.unwrap_or_default(); - let socket_table = register_internal_tools(&mut tools, &exit_code, network, listen_ports); + let socket_table = + register_internal_tools(&mut tools, &exit_code, &sleep_cancel, network, listen_ports); let tools = Arc::new(tools); let tools_ref = tools.clone(); usbox.register_host_function("__dispatch", move |payload: Vec| -> Vec { @@ -2213,6 +2254,7 @@ impl Sandbox { initrd_path.map(|p| p.to_path_buf()), INITRD_MAP_BASE, exit_code, + sleep_cancel, socket_table, ) } @@ -2222,6 +2264,7 @@ impl Sandbox { file_mapping_path: Option, file_mapping_base: u64, exit_code: Arc, + sleep_cancel: SleepCancel, socket_table: Option>>, ) -> Result { let mut inner = usbox.evolve()?; @@ -2233,6 +2276,7 @@ impl Sandbox { file_mapping_base, exit_code, socket_table, + sleep_cancel, }) } @@ -2305,6 +2349,12 @@ impl Sandbox { self.inner.interrupt_handle() } + /// Obtain the sleep-cancellation token. Call `.cancel()` on it to + /// wake any in-progress `__hl_sleep` immediately. + pub fn sleep_cancel(&self) -> SleepCancel { + self.sleep_cancel.clone() + } + /// Take a new snapshot of the current guest state. /// /// Useful for the "snapshot after one-time warm-up" pattern: call @@ -2418,8 +2468,10 @@ impl Sandbox { let arc = Arc::new(loaded); let exit_code = Arc::new(AtomicI32::new(0)); + let sleep_cancel = SleepCancel::new(); let mut tools = build_tools(None, preopens)?.unwrap_or_default(); - let socket_table = register_internal_tools(&mut tools, &exit_code, network, listen_ports); + let socket_table = + register_internal_tools(&mut tools, &exit_code, &sleep_cancel, network, listen_ports); let tools = Arc::new(tools); let tools_ref = tools.clone(); @@ -2442,6 +2494,7 @@ impl Sandbox { file_mapping_base: INITRD_MAP_BASE, exit_code, socket_table, + sleep_cancel, }) } } @@ -3165,10 +3218,12 @@ mod tests { fn net_tools_registered_with_blocklist() { let mut tools = ToolRegistry::new(); let exit_code = Arc::new(AtomicI32::new(0)); + let sc = SleepCancel::new(); let bl = BlockList::from_hosts(&["1.2.3.4"]).unwrap(); register_internal_tools( &mut tools, &exit_code, + &sc, Some(&NetworkPolicy::BlockList(bl)), None, ); @@ -3182,7 +3237,8 @@ mod tests { fn net_tools_not_registered_without_policy() { let mut tools = ToolRegistry::new(); let exit_code = Arc::new(AtomicI32::new(0)); - register_internal_tools(&mut tools, &exit_code, None, None); + let sc = SleepCancel::new(); + register_internal_tools(&mut tools, &exit_code, &sc, None, None); let req = br#"{"name":"net_socket","args":{"family":2,"type":1}}"#; let resp = tools.dispatch(req); let s = std::str::from_utf8(&resp).unwrap(); @@ -3193,7 +3249,8 @@ mod tests { fn net_tools_registered_with_allow_all() { let mut tools = ToolRegistry::new(); let exit_code = Arc::new(AtomicI32::new(0)); - register_internal_tools(&mut tools, &exit_code, Some(&NetworkPolicy::AllowAll), None); + let sc = SleepCancel::new(); + register_internal_tools(&mut tools, &exit_code, &sc, Some(&NetworkPolicy::AllowAll), None); let req = br#"{"name":"net_socket","args":{"family":2,"type":1}}"#; let resp = tools.dispatch(req); let s = std::str::from_utf8(&resp).unwrap(); @@ -3219,7 +3276,8 @@ mod tests { fn net_bind_denied_without_listen_ports() { let mut tools = ToolRegistry::new(); let exit_code = Arc::new(AtomicI32::new(0)); - register_internal_tools(&mut tools, &exit_code, Some(&NetworkPolicy::AllowAll), None); + let sc = SleepCancel::new(); + register_internal_tools(&mut tools, &exit_code, &sc, Some(&NetworkPolicy::AllowAll), None); // Create a socket first let req = br#"{"name":"net_socket","args":{"family":2,"type":1}}"#; let resp = tools.dispatch(req); @@ -3241,10 +3299,12 @@ mod tests { fn net_bind_allowed_with_matching_port() { let mut tools = ToolRegistry::new(); let exit_code = Arc::new(AtomicI32::new(0)); + let sc = SleepCancel::new(); let lp = ListenPorts::from_ports([8080]); register_internal_tools( &mut tools, &exit_code, + &sc, Some(&NetworkPolicy::AllowAll), Some(&lp), ); @@ -3265,10 +3325,12 @@ mod tests { fn net_bind_denied_with_wrong_port() { let mut tools = ToolRegistry::new(); let exit_code = Arc::new(AtomicI32::new(0)); + let sc = SleepCancel::new(); let lp = ListenPorts::from_ports([8080]); register_internal_tools( &mut tools, &exit_code, + &sc, Some(&NetworkPolicy::AllowAll), Some(&lp), ); @@ -3308,7 +3370,8 @@ mod tests { let mut tools = ToolRegistry::new(); let exit_code = Arc::new(AtomicI32::new(0)); - register_internal_tools(&mut tools, &exit_code, None, None); + let sc = SleepCancel::new(); + register_internal_tools(&mut tools, &exit_code, &sc, None, None); let req = br#"{"name":"__hl_sleep","args":{"ns":0}}"#; let resp = tools.dispatch(req); @@ -3316,6 +3379,35 @@ mod tests { assert!(!s.contains("\"error\""), "sleep(0) should succeed: {s}"); } + #[test] + fn test_sleep_cancel_wakes_immediately() { + let mut tools = ToolRegistry::new(); + let exit_code = Arc::new(AtomicI32::new(0)); + let sc = SleepCancel::new(); + register_internal_tools(&mut tools, &exit_code, &sc, None, None); + + let sc2 = sc.clone(); + let handle = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(200)); + sc2.cancel(); + }); + + let start = std::time::Instant::now(); + let req = br#"{"name":"__hl_sleep","args":{"ns":60000000000}}"#; + let resp = tools.dispatch(req); + let elapsed = start.elapsed(); + handle.join().unwrap(); + sc.reset(); + + let s = std::str::from_utf8(&resp).unwrap(); + assert!(!s.contains("\"error\""), "sleep should succeed: {s}"); + assert!( + elapsed.as_secs() < 5, + "cancelled sleep should wake promptly, took {:.1}s", + elapsed.as_secs_f64() + ); + } + #[test] fn net_getsockopt_returns_correct_type_for_dgram() { let mut reg = ToolRegistry::new(); @@ -3473,8 +3565,9 @@ mod tests { fn net_socket_has_default_timeout() { let mut tools = ToolRegistry::new(); let exit_code = Arc::new(AtomicI32::new(0)); + let sc = SleepCancel::new(); let table = - register_internal_tools(&mut tools, &exit_code, Some(&NetworkPolicy::AllowAll), None) + register_internal_tools(&mut tools, &exit_code, &sc, Some(&NetworkPolicy::AllowAll), None) .expect("network tools should be registered"); let req = br#"{"name":"net_socket","args":{"family":2,"type":1}}"#; diff --git a/host/src/pyhl.rs b/host/src/pyhl.rs index 7f302cb..9ba5fd8 100644 --- a/host/src/pyhl.rs +++ b/host/src/pyhl.rs @@ -332,6 +332,7 @@ impl Runtime { self.sandbox.reset_exit_code(); let handle = self.sandbox.interrupt_handle(); + let sleep_cancel = self.sandbox.sleep_cancel(); let done = Arc::new(std::sync::atomic::AtomicBool::new(false)); let done_clone = done.clone(); @@ -344,6 +345,7 @@ impl Runtime { std::thread::sleep(std::time::Duration::from_millis(10)); } if !done_clone.load(std::sync::atomic::Ordering::Relaxed) { + sleep_cancel.cancel(); handle.kill(); return true; } @@ -364,6 +366,7 @@ impl Runtime { } Err(_) if timed_out => { // Restore so the next call starts clean. + self.sandbox.sleep_cancel.reset(); self.sandbox.restore()?; Err(anyhow!( "execution timed out after {:.1}s", From 0f325bb255a933ebf828207f9968f4ae27388c5d Mon Sep 17 00:00:00 2001 From: danbugs Date: Tue, 19 May 2026 22:13:32 +0000 Subject: [PATCH 3/7] test: assert time.sleep kill latency under 10s Signed-off-by: danbugs --- host/tests/pyhl_runtime.rs | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/host/tests/pyhl_runtime.rs b/host/tests/pyhl_runtime.rs index ad9ba85..7c204de 100644 --- a/host/tests/pyhl_runtime.rs +++ b/host/tests/pyhl_runtime.rs @@ -344,20 +344,48 @@ fn runtime_network_disabled_by_default() { // --------------------------------------------------------------------------- #[test] -fn runtime_timeout_kills_long_running_code() { +fn runtime_timeout_kills_busy_spin() { let Some((_home, mut rt)) = setup() else { return; }; + let start = std::time::Instant::now(); let result = rt.run_code_with_timeout( - "import time; time.sleep(120); print('should not reach here')", + "while True: pass", std::time::Duration::from_secs(2), ); - assert!(result.is_err(), "long-running code should be killed"); + let elapsed = start.elapsed(); + assert!(result.is_err(), "busy spin should be killed"); let err = result.unwrap_err().to_string(); assert!( err.contains("timed out"), "error should mention timeout, got: {err}" ); + eprintln!("busy spin killed in {:.1}s", elapsed.as_secs_f64()); + assert!( + elapsed.as_secs() < 10, + "busy spin should be killed promptly, took {:.1}s", + elapsed.as_secs_f64() + ); +} + +#[test] +fn runtime_timeout_kills_time_sleep() { + let Some((_home, mut rt)) = setup() else { + return; + }; + let start = std::time::Instant::now(); + let result = rt.run_code_with_timeout( + "import time; time.sleep(120)", + std::time::Duration::from_secs(2), + ); + let elapsed = start.elapsed(); + assert!(result.is_err(), "sleeping code should be killed"); + eprintln!("time.sleep killed in {:.1}s", elapsed.as_secs_f64()); + assert!( + elapsed.as_secs() < 10, + "time.sleep should be killed promptly, took {:.1}s", + elapsed.as_secs_f64() + ); } #[test] From 8778258c3781707fe3b22921d4ce7a2cf9194409 Mon Sep 17 00:00:00 2001 From: danbugs Date: Tue, 19 May 2026 23:06:48 +0000 Subject: [PATCH 4/7] chore: bump version to 0.6.0 Signed-off-by: danbugs --- host/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/host/Cargo.toml b/host/Cargo.toml index e06326b..f69f6a2 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hyperlight-unikraft-host" -version = "0.5.0" +version = "0.6.0" edition = "2021" description = "Embedded Hyperlight host for running Unikraft unikernels" license = "MIT OR Apache-2.0" From eeea4c873bcdf2cdb722dd4cb259f411a45cccd2 Mon Sep 17 00:00:00 2001 From: danbugs Date: Tue, 19 May 2026 23:12:17 +0000 Subject: [PATCH 5/7] style: cargo fmt Signed-off-by: danbugs --- host/src/lib.rs | 27 ++++++++++++++++++++++----- host/tests/pyhl_runtime.rs | 10 ++-------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/host/src/lib.rs b/host/src/lib.rs index 9fcee98..d75790b 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -3250,7 +3250,13 @@ mod tests { let mut tools = ToolRegistry::new(); let exit_code = Arc::new(AtomicI32::new(0)); let sc = SleepCancel::new(); - register_internal_tools(&mut tools, &exit_code, &sc, Some(&NetworkPolicy::AllowAll), None); + register_internal_tools( + &mut tools, + &exit_code, + &sc, + Some(&NetworkPolicy::AllowAll), + None, + ); let req = br#"{"name":"net_socket","args":{"family":2,"type":1}}"#; let resp = tools.dispatch(req); let s = std::str::from_utf8(&resp).unwrap(); @@ -3277,7 +3283,13 @@ mod tests { let mut tools = ToolRegistry::new(); let exit_code = Arc::new(AtomicI32::new(0)); let sc = SleepCancel::new(); - register_internal_tools(&mut tools, &exit_code, &sc, Some(&NetworkPolicy::AllowAll), None); + register_internal_tools( + &mut tools, + &exit_code, + &sc, + Some(&NetworkPolicy::AllowAll), + None, + ); // Create a socket first let req = br#"{"name":"net_socket","args":{"family":2,"type":1}}"#; let resp = tools.dispatch(req); @@ -3566,9 +3578,14 @@ mod tests { let mut tools = ToolRegistry::new(); let exit_code = Arc::new(AtomicI32::new(0)); let sc = SleepCancel::new(); - let table = - register_internal_tools(&mut tools, &exit_code, &sc, Some(&NetworkPolicy::AllowAll), None) - .expect("network tools should be registered"); + let table = register_internal_tools( + &mut tools, + &exit_code, + &sc, + Some(&NetworkPolicy::AllowAll), + None, + ) + .expect("network tools should be registered"); let req = br#"{"name":"net_socket","args":{"family":2,"type":1}}"#; let resp = tools.dispatch(req); diff --git a/host/tests/pyhl_runtime.rs b/host/tests/pyhl_runtime.rs index 7c204de..66f372d 100644 --- a/host/tests/pyhl_runtime.rs +++ b/host/tests/pyhl_runtime.rs @@ -349,10 +349,7 @@ fn runtime_timeout_kills_busy_spin() { return; }; let start = std::time::Instant::now(); - let result = rt.run_code_with_timeout( - "while True: pass", - std::time::Duration::from_secs(2), - ); + let result = rt.run_code_with_timeout("while True: pass", std::time::Duration::from_secs(2)); let elapsed = start.elapsed(); assert!(result.is_err(), "busy spin should be killed"); let err = result.unwrap_err().to_string(); @@ -394,10 +391,7 @@ fn runtime_timeout_allows_fast_code() { return; }; let timing = rt - .run_code_with_timeout( - "print('fast')", - std::time::Duration::from_secs(10), - ) + .run_code_with_timeout("print('fast')", std::time::Duration::from_secs(10)) .unwrap(); assert_eq!(timing.exit_code, 0); } From 7933cdf89c425334481749cb0998d584b5b281af Mon Sep 17 00:00:00 2001 From: danbugs Date: Tue, 19 May 2026 23:13:12 +0000 Subject: [PATCH 6/7] fix: address copilot review feedback - Fix doc comment on SleepCancel (no is_cancelled method exists) - Use wait_timeout_while to handle spurious condvar wakeups - Reset sleep_cancel unconditionally after timer joins to handle race where timer fires as guest call completes Signed-off-by: danbugs --- host/src/lib.rs | 8 +++++--- host/src/pyhl.rs | 7 +++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/host/src/lib.rs b/host/src/lib.rs index d75790b..427e4c5 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -110,8 +110,8 @@ const MAX_SLEEP_NS: u64 = 60_000_000_000; /// Shared cancellation primitive for `__hl_sleep`. Calling /// [`SleepCancel::cancel`] wakes up any in-progress sleep immediately so -/// the host function returns and the execution loop can check -/// `is_cancelled()`. +/// the host function returns and the hypervisor execution loop can detect +/// the pending cancellation. #[derive(Clone)] pub struct SleepCancel(Arc<(Mutex, Condvar)>); @@ -138,7 +138,9 @@ impl SleepCancel { if *guard { return; } - let _ = cvar.wait_timeout(guard, dur); + // wait_timeout_while handles spurious wakeups by re-checking the + // predicate; we only return early when actually cancelled. + let _ = cvar.wait_timeout_while(guard, dur, |cancelled| !*cancelled); } } diff --git a/host/src/pyhl.rs b/host/src/pyhl.rs index 9ba5fd8..154bcd7 100644 --- a/host/src/pyhl.rs +++ b/host/src/pyhl.rs @@ -359,14 +359,17 @@ impl Runtime { done.store(true, std::sync::atomic::Ordering::Relaxed); let timed_out = timer.join().unwrap_or(false); + // Always reset so a subsequent guest call can sleep normally, even + // if the timer fired right as the call completed (race where + // timed_out=true but call_result=Ok). + self.sandbox.sleep_cancel.reset(); + match call_result { Ok(()) => { t.exit_code = self.sandbox.last_exit_code(); Ok(t) } Err(_) if timed_out => { - // Restore so the next call starts clean. - self.sandbox.sleep_cancel.reset(); self.sandbox.restore()?; Err(anyhow!( "execution timed out after {:.1}s", From 10f713ecaa5ff996ab6c95baa9472b1afa4ebc61 Mon Sep 17 00:00:00 2001 From: danbugs Date: Tue, 19 May 2026 23:18:19 +0000 Subject: [PATCH 7/7] chore: update Cargo.lock for 0.6.0 Signed-off-by: danbugs --- host/Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/host/Cargo.lock b/host/Cargo.lock index 0e78b0b..41938cc 100644 --- a/host/Cargo.lock +++ b/host/Cargo.lock @@ -636,7 +636,7 @@ dependencies = [ [[package]] name = "hyperlight-unikraft-host" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "base64",