Skip to content

Commit dfdc4bd

Browse files
committed
Expose cross-thread guest interruption in Python SDK
Signed-off-by: James Sturtevant <jsturtevant@gmail.com>
1 parent e973b5e commit dfdc4bd

5 files changed

Lines changed: 302 additions & 80 deletions

File tree

‎src/sdk/python/core/hyperlight_sandbox/__init__.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def _load_backend(backend: str):
6262
__all__ = [
6363
"CodeExecutionTool",
6464
"ExecutionResult",
65+
"InterruptHandle",
6566
"Sandbox",
6667
"SandboxEnvironment",
6768
"__version__",
@@ -104,6 +105,24 @@ class SandboxEnvironment:
104105
max_file_count: int | None = None
105106

106107

108+
class InterruptHandle:
109+
"""An independently owned handle that can interrupt a guest from any thread."""
110+
111+
def __init__(self, inner: Any) -> None:
112+
self._inner = inner
113+
114+
def kill(self) -> bool:
115+
"""Interrupt a running guest and wait for it to stop.
116+
117+
Returns whether the vCPU was running. False during a host callback can
118+
still leave cancellation pending until that callback returns.
119+
A request just before guest entry can be missed; callers can retry.
120+
Host callbacks are not interrupted. Restore a snapshot before reusing
121+
an interrupted sandbox. The handle can outlive its sandbox.
122+
"""
123+
return self._inner.kill()
124+
125+
107126
class Sandbox:
108127
"""Stable Python API over swappable Hyperlight backends.
109128
@@ -199,6 +218,7 @@ def register_tool(self, name_or_tool: Any, callback: Any | None = None) -> None:
199218
MAX_CODE_SIZE: int = 10 * 1024 * 1024
200219

201220
def run(self, code: str) -> ExecutionResult:
221+
"""Execute code, raising InterruptedError if a handle interrupts the guest."""
202222
if len(code) > self.MAX_CODE_SIZE:
203223
raise ValueError(f"code exceeds maximum size ({len(code)} > {self.MAX_CODE_SIZE} bytes)")
204224
native_result = self._inner.run(code)
@@ -208,6 +228,14 @@ def run(self, code: str) -> ExecutionResult:
208228
exit_code=native_result.exit_code,
209229
)
210230

231+
def interrupt_handle(self) -> InterruptHandle:
232+
"""Initialize the runtime if needed and return a thread-safe interrupt handle.
233+
234+
Register tools before obtaining the handle. Use the sandbox itself only
235+
on its creating thread; the independent handle can be shared.
236+
"""
237+
return InterruptHandle(self._inner.interrupt_handle())
238+
211239
def get_output_files(self) -> list[str]:
212240
"""List filenames written by the guest to the output directory.
213241
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Cross-thread guest interruption for both Python SDK backends."""
2+
3+
import threading
4+
import time
5+
import unittest
6+
7+
from hyperlight_sandbox import Sandbox
8+
9+
10+
class TestInterruption(unittest.TestCase):
11+
def test_idle_handle_and_handle_after_sandbox_drop(self):
12+
for backend in ("wasm", "hyperlight-js"):
13+
with self.subTest(backend=backend):
14+
sandbox = Sandbox(backend=backend)
15+
handle = sandbox.interrupt_handle()
16+
self.assertFalse(handle.kill())
17+
del sandbox
18+
self.assertFalse(handle.kill())
19+
20+
def test_interrupt_from_another_thread_and_restore(self):
21+
for backend in ("wasm", "hyperlight-js"):
22+
with self.subTest(backend=backend):
23+
self._interrupt_and_restore(backend)
24+
25+
def _interrupt_and_restore(self, backend):
26+
sandbox = Sandbox(backend=backend)
27+
started = threading.Event()
28+
finished = threading.Event()
29+
attempts = []
30+
31+
def ready() -> bool:
32+
started.set()
33+
return True
34+
35+
sandbox.register_tool("ready", ready)
36+
handle = sandbox.interrupt_handle()
37+
warmup = "print('ready')" if backend == "wasm" else "console.log('ready');"
38+
self.assertTrue(sandbox.run(warmup).success)
39+
snapshot = sandbox.snapshot()
40+
41+
def interrupt():
42+
if not started.wait(5):
43+
return
44+
if finished.wait(0.05):
45+
return
46+
while not finished.is_set():
47+
attempts.append(handle.kill())
48+
if attempts[-1]:
49+
return
50+
finished.wait(0.01)
51+
52+
worker = threading.Thread(target=interrupt, daemon=True)
53+
worker.start()
54+
code = (
55+
"import time\ncall_tool('ready')\nend = time.monotonic() + 5\n"
56+
"while time.monotonic() < end:\n"
57+
" for counter in range(100_000):\n value = counter * counter"
58+
if backend == "wasm"
59+
else "call_tool('ready'); const end = Date.now() + 5000; let value = 0;"
60+
"while (Date.now() < end) {"
61+
"for (let counter = 0; counter < 100000; counter++) { value = counter * counter; }}"
62+
)
63+
start = time.monotonic()
64+
try:
65+
with self.assertRaises(InterruptedError):
66+
sandbox.run(code)
67+
finally:
68+
finished.set()
69+
worker.join(5)
70+
self.assertFalse(worker.is_alive())
71+
self.assertIn(True, attempts)
72+
self.assertLess(time.monotonic() - start, 3)
73+
with self.assertRaises(RuntimeError):
74+
sandbox.run(warmup)
75+
sandbox.restore(snapshot)
76+
self.assertTrue(sandbox.run(warmup).success)
77+
78+
def test_interruption_does_not_abort_host_callback(self):
79+
for backend in ("wasm", "hyperlight-js"):
80+
with self.subTest(backend=backend):
81+
self._interrupt_host_callback(backend)
82+
83+
def _interrupt_host_callback(self, backend):
84+
sandbox = Sandbox(backend=backend)
85+
entered = threading.Event()
86+
release = threading.Event()
87+
finished = threading.Event()
88+
callback_completed = []
89+
attempts = []
90+
91+
def blocked() -> bool:
92+
entered.set()
93+
released = release.wait(5)
94+
callback_completed.append(released)
95+
return released
96+
97+
sandbox.register_tool("blocked", blocked)
98+
handle = sandbox.interrupt_handle()
99+
100+
def interrupt():
101+
try:
102+
if entered.wait(5):
103+
attempts.append(handle.kill())
104+
finally:
105+
release.set()
106+
while not finished.is_set():
107+
if handle.kill():
108+
return
109+
finished.wait(0.01)
110+
111+
worker = threading.Thread(target=interrupt, daemon=True)
112+
worker.start()
113+
code = (
114+
"import time\ncall_tool('blocked')\nend = time.monotonic() + 5\nwhile time.monotonic() < end:\n pass"
115+
if backend == "wasm"
116+
else "call_tool('blocked'); const end = Date.now() + 5000; while (Date.now() < end) {}"
117+
)
118+
try:
119+
with self.assertRaises(InterruptedError):
120+
sandbox.run(code)
121+
finally:
122+
finished.set()
123+
release.set()
124+
worker.join(5)
125+
self.assertFalse(worker.is_alive())
126+
self.assertEqual(attempts, [False])
127+
self.assertEqual(callback_completed, [True])
128+
129+
130+
if __name__ == "__main__":
131+
unittest.main()

‎src/sdk/python/hyperlight_js_backend/src/lib.rs‎

Lines changed: 58 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ use hyperlight_sandbox::{
66
MethodFilter, Sandbox, SandboxBuilder, SandboxConfig,
77
};
88
use hyperlight_sandbox_pyo3_common::{
9-
PyExecutionResult, build_tool_registry, parse_filesystem_limits, parse_size,
10-
parse_tool_registration,
9+
PyExecutionResult, PyInterruptHandle, build_tool_registry, execution_error,
10+
parse_filesystem_limits, parse_size, parse_tool_registration,
1111
};
1212
use pyo3::exceptions::PyRuntimeError;
1313
use pyo3::prelude::*;
@@ -34,6 +34,45 @@ pub struct JSSandbox {
3434
filesystem_limits: Option<FilesystemLimits>,
3535
}
3636

37+
impl JSSandbox {
38+
fn ensure_initialized(&mut self, py: Python<'_>) -> PyResult<()> {
39+
if self.inner.is_none() {
40+
let registry = build_tool_registry(py, &mut self.tools)?;
41+
let mut builder = SandboxBuilder::new()
42+
.module_path(&self.config.module_path)
43+
.heap_size(self.config.heap_size)
44+
.stack_size(self.config.stack_size)
45+
.with_tools(registry)
46+
.guest(HyperlightJs);
47+
if let Some(limits) = self.filesystem_limits {
48+
builder = builder.filesystem_limits(limits);
49+
}
50+
if let Some(ref dir) = self.input_dir {
51+
builder = builder.input_dir(dir);
52+
}
53+
if let Some(ref dir) = self.output_dir {
54+
builder = builder.output_dir(
55+
dir,
56+
DirPerms::READ | DirPerms::MUTATE,
57+
FilePerms::READ | FilePerms::WRITE,
58+
);
59+
} else if self.temp_output {
60+
builder = builder.temp_output();
61+
}
62+
let mut sandbox = builder
63+
.build()
64+
.map_err(|e| PyRuntimeError::new_err(format!("Failed to create sandbox: {e:#}")))?;
65+
for (target, methods) in std::mem::take(&mut self.pending_networks) {
66+
sandbox
67+
.allow_domain(&target, methods)
68+
.map_err(|e| PyRuntimeError::new_err(format!("{e}")))?;
69+
}
70+
self.inner = Some(sandbox);
71+
}
72+
Ok(())
73+
}
74+
}
75+
3776
#[pymethods]
3877
impl JSSandbox {
3978
#[new]
@@ -101,45 +140,24 @@ impl JSSandbox {
101140
Ok(())
102141
}
103142

143+
fn interrupt_handle(&mut self, py: Python<'_>) -> PyResult<PyInterruptHandle> {
144+
self.ensure_initialized(py)?;
145+
let inner = self
146+
.inner
147+
.as_ref()
148+
.unwrap()
149+
.interrupt_handle()
150+
.map_err(|error| {
151+
PyRuntimeError::new_err(format!("Failed to get interrupt handle: {error:#}"))
152+
})?;
153+
Ok(PyInterruptHandle { inner })
154+
}
155+
104156
#[pyo3(signature = (code))]
105-
fn run(&mut self, py: Python<'_>, code: &str) -> PyResult<PyExecutionResult> {
106-
if self.inner.is_none() {
107-
let registry = build_tool_registry(py, &mut self.tools)?;
108-
let mut builder = SandboxBuilder::new()
109-
.module_path(&self.config.module_path)
110-
.heap_size(self.config.heap_size)
111-
.stack_size(self.config.stack_size)
112-
.with_tools(registry)
113-
.guest(HyperlightJs);
114-
if let Some(limits) = self.filesystem_limits {
115-
builder = builder.filesystem_limits(limits);
116-
}
117-
if let Some(ref dir) = self.input_dir {
118-
builder = builder.input_dir(dir);
119-
}
120-
if let Some(ref dir) = self.output_dir {
121-
builder = builder.output_dir(
122-
dir,
123-
DirPerms::READ | DirPerms::MUTATE,
124-
FilePerms::READ | FilePerms::WRITE,
125-
);
126-
} else if self.temp_output {
127-
builder = builder.temp_output();
128-
}
129-
let mut sandbox = builder
130-
.build()
131-
.map_err(|e| PyRuntimeError::new_err(format!("Failed to create sandbox: {e:#}")))?;
132-
for (target, methods) in std::mem::take(&mut self.pending_networks) {
133-
sandbox
134-
.allow_domain(&target, methods)
135-
.map_err(|e| PyRuntimeError::new_err(format!("{e}")))?;
136-
}
137-
self.inner = Some(sandbox);
138-
}
157+
fn run(&mut self, py: Python<'_>, code: String) -> PyResult<PyExecutionResult> {
158+
self.ensure_initialized(py)?;
139159
let sandbox = self.inner.as_mut().unwrap();
140-
let result = sandbox
141-
.run(code)
142-
.map_err(|e| PyRuntimeError::new_err(format!("Execution failed: {e}")))?;
160+
let result = py.detach(|| sandbox.run(&code)).map_err(execution_error)?;
143161
Ok(PyExecutionResult {
144162
stdout: result.stdout,
145163
stderr: result.stderr,
@@ -210,6 +228,7 @@ fn _native_js(m: &Bound<'_, PyModule>) -> PyResult<()> {
210228
m.add_class::<JSSandbox>()?;
211229
m.add_class::<PyExecutionResult>()?;
212230
m.add_class::<PySnapshot>()?;
231+
m.add_class::<PyInterruptHandle>()?;
213232
m.add("__version__", "0.1.0")?;
214233
Ok(())
215234
}

‎src/sdk/python/pyo3_common/src/lib.rs‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,33 @@
1-
use hyperlight_sandbox::{ArgType, FilesystemLimits, ToolRegistry, ToolSchema};
2-
use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError};
1+
use hyperlight_sandbox::{
2+
ArgType, FilesystemLimits, HyperlightError, InterruptHandle, ToolRegistry, ToolSchema,
3+
};
4+
use pyo3::exceptions::{PyInterruptedError, PyRuntimeError, PyTypeError, PyValueError};
35
use pyo3::prelude::*;
46
use pyo3::types::{PyDict, PyModule};
57

8+
#[pyclass(frozen, name = "InterruptHandle")]
9+
pub struct PyInterruptHandle {
10+
pub inner: InterruptHandle,
11+
}
12+
13+
#[pymethods]
14+
impl PyInterruptHandle {
15+
fn kill(&self, py: Python<'_>) -> bool {
16+
py.detach(|| self.inner.kill())
17+
}
18+
}
19+
20+
pub fn execution_error(error: anyhow::Error) -> PyErr {
21+
if matches!(
22+
error.downcast_ref::<HyperlightError>(),
23+
Some(HyperlightError::ExecutionCanceledByHost())
24+
) {
25+
PyInterruptedError::new_err(format!("Execution interrupted: {error:#}"))
26+
} else {
27+
PyRuntimeError::new_err(format!("Execution failed: {error:#}"))
28+
}
29+
}
30+
631
/// Convert a human-readable size string (e.g. `"200Mi"`) to bytes.
732
pub fn parse_size(size: &str) -> PyResult<u64> {
833
parse_size_value(size).map_err(PyRuntimeError::new_err)

0 commit comments

Comments
 (0)