Skip to content

Commit 545a77a

Browse files
fix(python-guest): persist module globals across run() calls (#93)
* 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. Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com> * Allow uv prerelease resolution Signed-off-by: James Sturtevant <jsturtevant@gmail.com> * Clean up Python state persistence tests Signed-off-by: James Sturtevant <jsturtevant@gmail.com> --------- Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com> Signed-off-by: James Sturtevant <jsturtevant@gmail.com> Co-authored-by: James Sturtevant <jsturtevant@gmail.com>
1 parent e32e018 commit 545a77a

4 files changed

Lines changed: 205 additions & 20 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ requires-python = ">=3.10"
55

66
[tool.uv]
77
package = false
8+
prerelease = "allow"
89

910
[tool.uv.workspace]
1011
members = [

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: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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. It tests:
6+
//!
7+
//! * the `WasmSandbox` `snapshot`/`restore` resets state — the documented
8+
//! mechanism for rewinding guest state
9+
//! * if state otherwise survives a `run()` boundary
10+
//! * behaves like the JavaScript guest, which preserves `globalThis` across runs
11+
//!
12+
13+
use std::path::Path;
14+
15+
use hyperlight_sandbox::SandboxBuilder;
16+
use hyperlight_wasm_sandbox::Wasm;
17+
18+
fn python_guest_path() -> String {
19+
Path::new(env!("CARGO_MANIFEST_DIR"))
20+
.join("guests/python/python-sandbox.aot")
21+
.display()
22+
.to_string()
23+
}
24+
25+
/// A `def` at module top level in `run()` must be callable in the second `run()`
26+
#[test]
27+
fn python_function_definition_persists_across_runs() {
28+
let mut sandbox = SandboxBuilder::new()
29+
.guest(Wasm)
30+
.module_path(python_guest_path())
31+
.build()
32+
.expect("failed to create sandbox");
33+
34+
let snap = sandbox.snapshot().expect("snapshot failed");
35+
sandbox
36+
.run("def word_count(text): return len(text.split())")
37+
.expect("first run failed");
38+
39+
let persist_result = sandbox
40+
.run("print(word_count('hello world from hyperlight'))")
41+
.expect("second run failed");
42+
43+
sandbox.restore(&snap).expect("restore failed");
44+
let reset_result = sandbox
45+
.run(
46+
r#"
47+
try:
48+
print(word_count('hello world from hyperlight'))
49+
except NameError:
50+
print("word_count is undefined")
51+
"#,
52+
)
53+
.expect("post-restore run failed");
54+
55+
assert_eq!(
56+
persist_result.exit_code, 0,
57+
"stderr: {}",
58+
persist_result.stderr
59+
);
60+
assert_eq!(persist_result.stdout.trim(), "4");
61+
assert_eq!(reset_result.exit_code, 0, "stderr: {}", reset_result.stderr);
62+
assert_eq!(reset_result.stdout.trim(), "word_count is undefined");
63+
}
64+
65+
/// A bare module-level assignment in `run()` must be readable in the second `run()`
66+
#[test]
67+
fn python_top_level_assignment_persists_across_runs() {
68+
let mut sandbox = SandboxBuilder::new()
69+
.guest(Wasm)
70+
.module_path(python_guest_path())
71+
.build()
72+
.expect("failed to create sandbox");
73+
74+
let snap = sandbox.snapshot().expect("snapshot failed");
75+
sandbox.run("counter = 100").expect("first run failed");
76+
let persist_result = sandbox
77+
.run("print(f'counter = {counter}')")
78+
.expect("second run failed");
79+
80+
sandbox.restore(&snap).expect("restore failed");
81+
let reset_result = sandbox
82+
.run(
83+
r#"
84+
try:
85+
print(f'counter = {counter}')
86+
except NameError:
87+
print("counter is undefined")
88+
"#,
89+
)
90+
.expect("post-restore run failed");
91+
92+
assert_eq!(
93+
persist_result.exit_code, 0,
94+
"stderr: {}",
95+
persist_result.stderr
96+
);
97+
assert_eq!(persist_result.stdout.trim(), "counter = 100");
98+
assert_eq!(reset_result.exit_code, 0, "stderr: {}", reset_result.stderr);
99+
assert_eq!(reset_result.stdout.trim(), "counter is undefined");
100+
}
101+
102+
/// `snapshot` + `restore` must continue to rewind the persistent
103+
/// namespace, undoing any names defined since the snapshot. This is
104+
/// the contract documented on `WasmSandbox`; the persistence fix must
105+
/// not regress it.
106+
#[test]
107+
fn python_restore_rewinds_module_globals() {
108+
let mut sandbox = SandboxBuilder::new()
109+
.guest(Wasm)
110+
.module_path(python_guest_path())
111+
.build()
112+
.expect("failed to create sandbox");
113+
114+
let snap = sandbox.snapshot().expect("snapshot failed");
115+
sandbox
116+
.run("rolled_back = 'still here'")
117+
.expect("set failed");
118+
sandbox.restore(&snap).expect("restore failed");
119+
120+
let result = sandbox
121+
.run(
122+
r#"
123+
try:
124+
print(rolled_back)
125+
except NameError:
126+
print("rolled_back is undefined")
127+
"#,
128+
)
129+
.expect("post-restore run failed");
130+
131+
assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
132+
assert_eq!(result.stdout.trim(), "rolled_back is undefined");
133+
}

uv.lock

Lines changed: 40 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)