Skip to content

Commit f8b0a4a

Browse files
committed
fix(python-guest): persist module globals across run() calls
The Executor previously rebuilt the globals dict on every call to run() via an inline 'exec(code, {...})' literal. That silently discarded every 'def', 'class', and top-level assignment between runs on the same sandbox instance, breaking three documented contracts: * WasmSandbox's snapshot/restore is the mechanism for rewinding guest state - a bare back-to-back run() boundary was never specified as a state-wipe. * The python_basics example explicitly sets 'counter = 100' and only expects it to disappear after restore(); the prior implementation made restore() a no-op because the counter would have been wiped by the very next run() anyway. * The JS guest preserves globalThis across run() calls; the Python guest had no equivalent persistence path. Fix: construct the globals dict once in Executor.__init__ and pass the instance attribute to every exec(). Snapshot/restore continues to rewind the namespace because it lives in the guest's Wasm linear memory. Adds tests/python_state_persistence.rs covering: top-level def reuse, bare assignment reuse, and snapshot/restore rewind of the persistent namespace.
1 parent 860d410 commit f8b0a4a

2 files changed

Lines changed: 145 additions & 2 deletions

File tree

src/wasm_sandbox/guests/python/sandbox_executor.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,36 @@ def _http_request(method: str, url: str, body: str = "", content_type: str = "")
139139

140140

141141
class Executor:
142-
"""Implements the WIT executor interface for componentize-py."""
142+
"""Implements the WIT executor interface for componentize-py.
143+
144+
The executor keeps a single, persistent module-level namespace
145+
(``self._globals``) that is reused across every call to :py:meth:`run`.
146+
Names defined by guest code (``x = 1``, ``def foo(): ...``,
147+
``class C: ...``) therefore remain visible to subsequent runs on
148+
the same sandbox instance, matching:
149+
150+
* the snapshot/restore contract documented on ``WasmSandbox`` —
151+
``snapshot``/``restore`` is the mechanism for rewinding state,
152+
not bare back-to-back ``run`` calls;
153+
* the JavaScript guest's ``globalThis`` persistence story for
154+
explicit global writes;
155+
* the ``python_basics`` example, which sets ``counter = 100``
156+
and treats ``restore`` (not the next ``run``) as the action
157+
that makes ``counter`` undefined.
158+
159+
Host-provided helpers (``call_tool``, ``http_get``, ``http_post``)
160+
are seeded once on construction. Guest code may shadow them
161+
locally, but the originals are restored by ``snapshot``/``restore``
162+
along with the rest of the namespace.
163+
"""
164+
165+
def __init__(self) -> None:
166+
self._globals: dict = {
167+
"__builtins__": __builtins__,
168+
"call_tool": _call_tool,
169+
"http_get": http_get,
170+
"http_post": http_post,
171+
}
143172

144173
def run(self, code: str) -> ExecutionResult:
145174
"""Execute Python code and capture output."""
@@ -152,7 +181,7 @@ def run(self, code: str) -> ExecutionResult:
152181

153182
exit_code = 0
154183
try:
155-
exec(code, {"__builtins__": __builtins__, "call_tool": _call_tool, "http_get": http_get, "http_post": http_post})
184+
exec(code, self._globals)
156185
except SystemExit as e:
157186
exit_code = e.code if isinstance(e.code, int) else 1
158187
except Exception as e:
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
//! Integration test: Python guest module globals persist across `run()`.
2+
//!
3+
//! This test pins the documented contract that the Python `Executor`
4+
//! reuses one module-level namespace for every call to `run()` on the
5+
//! same sandbox instance. The previous implementation built a fresh
6+
//! `globals` dict on every call (`exec(code, {...})`), which silently
7+
//! discarded any `def`, `class`, or top-level assignment between runs.
8+
//! That contradicted:
9+
//!
10+
//! * the `WasmSandbox` `snapshot`/`restore` contract — the documented
11+
//! mechanism for rewinding guest state — which only makes sense
12+
//! if state otherwise survives a `run()` boundary;
13+
//! * the `python_basics` example's "state was rolled back" narrative;
14+
//! * the JavaScript guest, which preserves `globalThis` across runs.
15+
//!
16+
//! The tests below would have failed on the prior implementation; they
17+
//! pass once `Executor` stores its globals on the instance and reuses
18+
//! them across `run()` calls.
19+
20+
use std::path::Path;
21+
22+
use hyperlight_sandbox::SandboxBuilder;
23+
use hyperlight_wasm_sandbox::Wasm;
24+
25+
fn python_guest_path() -> String {
26+
Path::new(env!("CARGO_MANIFEST_DIR"))
27+
.join("guests/python/python-sandbox.aot")
28+
.display()
29+
.to_string()
30+
}
31+
32+
/// A `def` at module top level in `run()` #1 must be callable in `run()` #2.
33+
#[tokio::test]
34+
async fn python_function_definition_persists_across_runs() {
35+
let result = tokio::task::spawn_blocking(|| {
36+
let mut sandbox = SandboxBuilder::new()
37+
.guest(Wasm)
38+
.module_path(python_guest_path())
39+
.build()
40+
.expect("failed to create sandbox");
41+
42+
sandbox
43+
.run("def word_count(text): return len(text.split())")
44+
.expect("first run failed");
45+
46+
sandbox
47+
.run("print(word_count('hello world from hyperlight'))")
48+
.expect("second run failed")
49+
})
50+
.await
51+
.unwrap();
52+
53+
assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
54+
assert_eq!(result.stdout.trim(), "4");
55+
}
56+
57+
/// A bare module-level assignment in `run()` #1 must be readable in `run()` #2.
58+
#[tokio::test]
59+
async fn python_top_level_assignment_persists_across_runs() {
60+
let result = tokio::task::spawn_blocking(|| {
61+
let mut sandbox = SandboxBuilder::new()
62+
.guest(Wasm)
63+
.module_path(python_guest_path())
64+
.build()
65+
.expect("failed to create sandbox");
66+
67+
sandbox.run("counter = 100").expect("first run failed");
68+
sandbox
69+
.run("print(f'counter = {counter}')")
70+
.expect("second run failed")
71+
})
72+
.await
73+
.unwrap();
74+
75+
assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
76+
assert_eq!(result.stdout.trim(), "counter = 100");
77+
}
78+
79+
/// `snapshot` + `restore` must continue to rewind the persistent
80+
/// namespace, undoing any names defined since the snapshot. This is
81+
/// the contract documented on `WasmSandbox`; the persistence fix must
82+
/// not regress it.
83+
#[tokio::test]
84+
async fn python_restore_rewinds_module_globals() {
85+
let result = tokio::task::spawn_blocking(|| {
86+
let mut sandbox = SandboxBuilder::new()
87+
.guest(Wasm)
88+
.module_path(python_guest_path())
89+
.build()
90+
.expect("failed to create sandbox");
91+
92+
let snap = sandbox.snapshot().expect("snapshot failed");
93+
sandbox
94+
.run("rolled_back = 'still here'")
95+
.expect("set failed");
96+
sandbox.restore(&snap).expect("restore failed");
97+
98+
sandbox
99+
.run(
100+
r#"
101+
try:
102+
print(rolled_back)
103+
except NameError:
104+
print("rolled_back is undefined")
105+
"#,
106+
)
107+
.expect("post-restore run failed")
108+
})
109+
.await
110+
.unwrap();
111+
112+
assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
113+
assert_eq!(result.stdout.trim(), "rolled_back is undefined");
114+
}

0 commit comments

Comments
 (0)