Skip to content

Commit a53e011

Browse files
committed
propagate guest exit codes and implement host-mediated time.sleep
Three behavioral fixes for python-agent-driver on Hyperlight: 1. Exception exit codes: PyRun_SimpleString swallowed exceptions. Replace with PyRun_String + manual exception handling in run_code_with_exceptions(). Non-zero exit codes reported to host via __hl_exit /dev/hcall side channel. 2. sys.exit(N) propagation: SystemExit was caught by CPython's internal handler which called exit() — Hyperlight's VmExit::Halt doesn't carry exit codes. Now intercepted before CPython handles it, code extracted and reported via __hl_exit. 3. time.sleep() was a no-op: Unikraft's cooperative scheduler on Hyperlight has no timer interrupt, so nanosleep returns immediately. Monkey-patch time.sleep during init to route through __hl_sleep host call, which blocks the host thread. Host side: Sandbox gains exit_code field (Arc<AtomicI32>), always registers __dispatch with __hl_exit and __hl_sleep handlers via register_internal_tools(). RunTiming exposes exit_code. pyhl binary exits with the guest's reported code. Signed-off-by: danbugs <danilochiarlone@gmail.com>
1 parent 9d37a86 commit a53e011

4 files changed

Lines changed: 167 additions & 53 deletions

File tree

examples/python-agent-driver/hl_pydriver.c

Lines changed: 95 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
#include <stdlib.h>
3636
#include <string.h>
3737
#include <unistd.h>
38+
#include <fcntl.h>
3839
#include <sys/syscall.h>
3940

4041
/* -- Minimal FlatBuffers reader for the incoming FunctionCall ---------
@@ -137,14 +138,69 @@ static inline void wrmsr_fsbase(uint64_t v)
137138

138139
/* -- Core work ------------------------------------------------------- */
139140

141+
static void report_exit_code(int code)
142+
{
143+
char req[128];
144+
int n = snprintf(req, sizeof(req),
145+
"{\"name\":\"__hl_exit\",\"args\":{\"code\":%d}}", code);
146+
int fd = open("/dev/hcall", O_RDWR);
147+
if (fd < 0)
148+
return;
149+
write(fd, req, n);
150+
char resp[128];
151+
read(fd, resp, sizeof(resp));
152+
close(fd);
153+
}
154+
155+
static int run_code_with_exceptions(const char *code)
156+
{
157+
PyObject *m = PyImport_AddModule("__main__");
158+
if (!m) return 1;
159+
PyObject *d = PyModule_GetDict(m);
160+
if (!d) return 1;
161+
162+
PyObject *result = PyRun_String(code, Py_file_input, d, d);
163+
if (result) {
164+
Py_DECREF(result);
165+
return 0;
166+
}
167+
168+
if (PyErr_ExceptionMatches(PyExc_SystemExit)) {
169+
PyObject *type, *value, *tb;
170+
PyErr_Fetch(&type, &value, &tb);
171+
PyErr_NormalizeException(&type, &value, &tb);
172+
int exit_code = 1;
173+
if (value) {
174+
PyObject *ca = PyObject_GetAttrString(value, "code");
175+
if (ca) {
176+
if (PyLong_Check(ca)) {
177+
exit_code = (int)PyLong_AsLong(ca);
178+
} else if (ca == Py_None) {
179+
exit_code = 0;
180+
} else {
181+
PyObject *s = PyObject_Str(ca);
182+
if (s) {
183+
const char *msg = PyUnicode_AsUTF8(s);
184+
if (msg)
185+
fprintf(stderr, "%s\n", msg);
186+
Py_DECREF(s);
187+
}
188+
}
189+
Py_DECREF(ca);
190+
}
191+
}
192+
Py_XDECREF(type);
193+
Py_XDECREF(value);
194+
Py_XDECREF(tb);
195+
return exit_code;
196+
}
197+
198+
PyErr_Print();
199+
return 1;
200+
}
201+
140202
static void py_run_user_code(const uint8_t *fc, size_t fc_len)
141203
{
142-
/* Restore Python's FS_BASE. Between calls, the kernel-side
143-
* dispatch_prepare (and possibly Hyperlight's own segment
144-
* restore) can leave FS_BASE at a kernel-default value, which
145-
* breaks Python's TLS-reliant code (PyGILState, pthread local
146-
* state, …) the moment we touch it. g_py_fsbase was captured
147-
* at the end of the one-time init below. */
148204
if (g_py_fsbase)
149205
wrmsr_fsbase(g_py_fsbase);
150206

@@ -153,23 +209,27 @@ static void py_run_user_code(const uint8_t *fc, size_t fc_len)
153209
if (!code)
154210
return;
155211

156-
/* PyRun_SimpleString wants a NUL-terminated C string; the FC
157-
* hlstring isn't NUL-terminated in its on-the-wire form. Small
158-
* copy into a stack/heap buffer to terminate. */
159-
if (code_len < 4096) {
160-
char buf[4096];
161-
memcpy(buf, code, code_len);
162-
buf[code_len] = '\0';
163-
PyRun_SimpleString(buf);
212+
char stack_buf[4096];
213+
char *buf;
214+
if (code_len < sizeof(stack_buf)) {
215+
memcpy(stack_buf, code, code_len);
216+
stack_buf[code_len] = '\0';
217+
buf = stack_buf;
164218
} else {
165-
char *buf = malloc(code_len + 1);
219+
buf = malloc(code_len + 1);
166220
if (!buf)
167221
return;
168222
memcpy(buf, code, code_len);
169223
buf[code_len] = '\0';
170-
PyRun_SimpleString(buf);
171-
free(buf);
172224
}
225+
226+
int exit_code = run_code_with_exceptions(buf);
227+
228+
if (buf != stack_buf)
229+
free(buf);
230+
231+
if (exit_code != 0)
232+
report_exit_code(exit_code);
173233
}
174234

175235
static void py_initialize_once(void)
@@ -202,6 +262,24 @@ static void py_initialize_once(void)
202262
" importlib.import_module(_mod)\n"
203263
" except Exception as _e:\n"
204264
" sys.stderr.write(f'warn: preload {_mod} failed: {_e}\\n')\n");
265+
266+
/* Monkey-patch time.sleep to call the host via /dev/hcall.
267+
* Unikraft's cooperative scheduler on Hyperlight has no timer
268+
* interrupt, so the kernel-level nanosleep is a no-op. This
269+
* routes the sleep to the host thread which actually blocks. */
270+
PyRun_SimpleString(
271+
"import time as _hl_time\n"
272+
"def _hl_sleep(secs):\n"
273+
" if secs <= 0:\n"
274+
" return\n"
275+
" import json\n"
276+
" fd = open('/dev/hcall', 'r+b', buffering=0)\n"
277+
" fd.write(json.dumps("
278+
"{'name':'__hl_sleep','args':{'ns':int(secs*1e9)}}).encode())\n"
279+
" fd.read()\n"
280+
" fd.close()\n"
281+
"_hl_time.sleep = _hl_sleep\n"
282+
"del _hl_time, _hl_sleep\n");
205283
}
206284

207285
/* -- Entry points --------------------------------------------------- */

host/src/bin/pyhl.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -504,14 +504,19 @@ fn cmd_run(args: RunArgs) -> Result<()> {
504504
full
505505
};
506506

507+
sandbox.reset_exit_code();
507508
let t_call = Instant::now();
508509
let _: () = sandbox.call_named("run", payload)?;
509510
let call_ms = t_call.elapsed().as_secs_f64() * 1000.0;
511+
let exit_code = sandbox.last_exit_code();
510512
if args.verbose {
511513
eprintln!(
512-
"[pyhl] run {i}/{total} restore={restore_ms:.1}ms call={call_ms:.1}ms (hermetic)"
514+
"[pyhl] run {i}/{total} restore={restore_ms:.1}ms call={call_ms:.1}ms exit={exit_code} (hermetic)"
513515
);
514516
}
517+
if exit_code != 0 {
518+
std::process::exit(exit_code);
519+
}
515520
}
516521

517522
Ok(())

host/src/lib.rs

Lines changed: 63 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ use hyperlight_host::sandbox::SandboxConfiguration;
6767
use hyperlight_host::{GuestBinary, HostFunctions, MultiUseSandbox, UninitializedSandbox};
6868
use std::collections::HashMap;
6969
use std::path::Path;
70+
use std::sync::atomic::{AtomicI32, Ordering};
7071
use std::sync::Arc;
7172
use std::time::Duration;
7273

@@ -791,6 +792,25 @@ fn build_tools(
791792
Ok(Some(registry))
792793
}
793794

795+
/// Register internal tools (`__hl_exit`, `__hl_sleep`) on a tool registry.
796+
/// These are plumbing used by the guest driver (`hl_pydriver.c`) and are
797+
/// always present regardless of user-supplied tools or preopens.
798+
fn register_internal_tools(tools: &mut ToolRegistry, exit_code: &Arc<AtomicI32>) {
799+
let ec = exit_code.clone();
800+
tools.register("__hl_exit", move |args| {
801+
let code = args["code"].as_i64().unwrap_or(1) as i32;
802+
ec.store(code, Ordering::Relaxed);
803+
Ok(serde_json::json!({}))
804+
});
805+
tools.register("__hl_sleep", |args| {
806+
let ns = args["ns"].as_u64().unwrap_or(0);
807+
if ns > 0 {
808+
std::thread::sleep(std::time::Duration::from_nanos(ns));
809+
}
810+
Ok(serde_json::json!({}))
811+
});
812+
}
813+
794814
/// Routes incoming fs_* tool calls to the matching `FsSandbox` by
795815
/// matching the guest-supplied path against each preopen's guest path.
796816
#[derive(Clone)]
@@ -1041,6 +1061,7 @@ pub struct Sandbox {
10411061
/// Snapshot restore unmaps all non-snapshot regions.
10421062
file_mapping_path: Option<std::path::PathBuf>,
10431063
file_mapping_base: u64,
1064+
exit_code: Arc<AtomicI32>,
10441065
}
10451066

10461067
/// Where the initrd comes from — either a file (zero-copy `map_file_cow`)
@@ -1213,17 +1234,16 @@ impl Sandbox {
12131234

12141235
let mut usbox = UninitializedSandbox::new(env, Some(config.sandbox_config()))?;
12151236

1216-
let tools = build_tools(tools, preopens)?;
1217-
1218-
if let Some(tools) = tools {
1219-
let tools = Arc::new(tools);
1220-
let tools_ref = tools.clone();
1221-
usbox.register_host_function("__dispatch", move |payload: Vec<u8>| -> Vec<u8> {
1222-
tools_ref.dispatch(&payload)
1223-
})?;
1224-
}
1237+
let exit_code = Arc::new(AtomicI32::new(0));
1238+
let mut tools = build_tools(tools, preopens)?.unwrap_or_default();
1239+
register_internal_tools(&mut tools, &exit_code);
1240+
let tools = Arc::new(tools);
1241+
let tools_ref = tools.clone();
1242+
usbox.register_host_function("__dispatch", move |payload: Vec<u8>| -> Vec<u8> {
1243+
tools_ref.dispatch(&payload)
1244+
})?;
12251245

1226-
Self::finish_evolve(usbox, None, 0)
1246+
Self::finish_evolve(usbox, None, 0, exit_code)
12271247
}
12281248

12291249
/// Low-level: boot with a zero-copy mapped initrd file. Prefer the builder.
@@ -1263,24 +1283,23 @@ impl Sandbox {
12631283
usbox.map_file_cow(path, INITRD_MAP_BASE, Some("initrd"))?;
12641284
}
12651285

1266-
let tools = build_tools(tools, preopens)?;
1267-
1268-
// Register tool dispatch if needed
1269-
if let Some(tools) = tools {
1270-
let tools = Arc::new(tools);
1271-
let tools_ref = tools.clone();
1272-
usbox.register_host_function("__dispatch", move |payload: Vec<u8>| -> Vec<u8> {
1273-
tools_ref.dispatch(&payload)
1274-
})?;
1275-
}
1286+
let exit_code = Arc::new(AtomicI32::new(0));
1287+
let mut tools = build_tools(tools, preopens)?.unwrap_or_default();
1288+
register_internal_tools(&mut tools, &exit_code);
1289+
let tools = Arc::new(tools);
1290+
let tools_ref = tools.clone();
1291+
usbox.register_host_function("__dispatch", move |payload: Vec<u8>| -> Vec<u8> {
1292+
tools_ref.dispatch(&payload)
1293+
})?;
12761294

1277-
Self::finish_evolve(usbox, initrd_path.map(|p| p.to_path_buf()), INITRD_MAP_BASE)
1295+
Self::finish_evolve(usbox, initrd_path.map(|p| p.to_path_buf()), INITRD_MAP_BASE, exit_code)
12781296
}
12791297

12801298
fn finish_evolve(
12811299
usbox: UninitializedSandbox,
12821300
file_mapping_path: Option<std::path::PathBuf>,
12831301
file_mapping_base: u64,
1302+
exit_code: Arc<AtomicI32>,
12841303
) -> Result<Self> {
12851304
let mut inner = usbox.evolve()?;
12861305
let snapshot = inner.snapshot().ok();
@@ -1289,6 +1308,7 @@ impl Sandbox {
12891308
snapshot,
12901309
file_mapping_path,
12911310
file_mapping_base,
1311+
exit_code,
12921312
})
12931313
}
12941314

@@ -1339,6 +1359,18 @@ impl Sandbox {
13391359
Ok(self.inner.call(func_name, args)?)
13401360
}
13411361

1362+
/// Read the exit code reported by the guest via `__hl_exit`.
1363+
/// Defaults to 0 (success) if the guest never called it.
1364+
pub fn last_exit_code(&self) -> i32 {
1365+
self.exit_code.load(Ordering::Relaxed)
1366+
}
1367+
1368+
/// Reset the stored exit code to 0. Call before each guest
1369+
/// invocation so a previous non-zero code doesn't leak.
1370+
pub fn reset_exit_code(&self) {
1371+
self.exit_code.store(0, Ordering::Relaxed);
1372+
}
1373+
13421374
/// Take a new snapshot of the current guest state.
13431375
///
13441376
/// Useful for the "snapshot after one-time warm-up" pattern: call
@@ -1405,21 +1437,16 @@ impl Sandbox {
14051437
let loaded = Snapshot::from_file_unchecked(path.as_ref())?;
14061438
let arc = Arc::new(loaded);
14071439

1440+
let exit_code = Arc::new(AtomicI32::new(0));
1441+
let mut tools = build_tools(None, preopens)?.unwrap_or_default();
1442+
register_internal_tools(&mut tools, &exit_code);
1443+
let tools = Arc::new(tools);
1444+
let tools_ref = tools.clone();
1445+
14081446
let mut host_funcs = HostFunctions::default();
1409-
if !preopens.is_empty() {
1410-
if let Some(tools) = build_tools(None, preopens)? {
1411-
let tools = Arc::new(tools);
1412-
let tools_ref = tools.clone();
1413-
host_funcs
1414-
.register_host_function("__dispatch", move |payload: Vec<u8>| -> Vec<u8> {
1415-
tools_ref.dispatch(&payload)
1416-
})?;
1417-
}
1418-
} else {
1419-
host_funcs.register_host_function("__dispatch", |_payload: Vec<u8>| -> Vec<u8> {
1420-
Vec::new()
1421-
})?;
1422-
}
1447+
host_funcs.register_host_function("__dispatch", move |payload: Vec<u8>| -> Vec<u8> {
1448+
tools_ref.dispatch(&payload)
1449+
})?;
14231450

14241451
let inner = MultiUseSandbox::from_snapshot(arc.clone(), host_funcs, None)?;
14251452

@@ -1428,6 +1455,7 @@ impl Sandbox {
14281455
snapshot: Some(arc),
14291456
file_mapping_path: None,
14301457
file_mapping_base: 0,
1458+
exit_code,
14311459
})
14321460
}
14331461
}

host/src/pyhl.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ pub fn install(opts: &InstallOptions<'_>) -> Result<InstallReport> {
197197
pub struct RunTiming {
198198
pub restore_ms: f64,
199199
pub call_ms: f64,
200+
pub exit_code: i32,
200201
}
201202

202203
/// A pyhl runtime backed by a warmed-up snapshot. Cheap to keep around;
@@ -244,10 +245,12 @@ impl Runtime {
244245
t.restore_ms = tr.elapsed().as_secs_f64() * 1000.0;
245246
}
246247
self.first_run = false;
248+
self.sandbox.reset_exit_code();
247249

248250
let tc = Instant::now();
249251
let _: () = self.sandbox.call_named("run", code.to_string())?;
250252
t.call_ms = tc.elapsed().as_secs_f64() * 1000.0;
253+
t.exit_code = self.sandbox.last_exit_code();
251254
Ok(t)
252255
}
253256

0 commit comments

Comments
 (0)