Skip to content

Commit 22e41e5

Browse files
Clean up Hyperlight integration sandboxes
Close real sandbox fixtures and provider-owned registries in Hyperlight integration tests so they do not rely on process teardown. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent fd710eb commit 22e41e5

1 file changed

Lines changed: 155 additions & 107 deletions

File tree

python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py

Lines changed: 155 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -72,17 +72,22 @@ def _hyperlight_integration_runtime_skip_reason() -> str | None:
7272
if (reason := _hyperlight_integration_static_skip_reason()) is not None:
7373
return reason
7474

75+
sandbox: Any | None = None
7576
try:
7677
sandbox_cls = execute_code_module._load_sandbox_class()
77-
sandbox = sandbox_cls(
78+
sandbox_instance = sandbox_cls(
7879
backend=execute_code_module.DEFAULT_HYPERLIGHT_BACKEND,
7980
module=execute_code_module.DEFAULT_HYPERLIGHT_MODULE,
8081
)
81-
sandbox.run("None")
82+
sandbox = sandbox_instance
83+
sandbox_instance.run("None")
8284
except RuntimeError as exc:
8385
message = str(exc)
8486
if "no hypervisor was found for sandbox" in message.lower():
8587
return "Hyperlight integration tests require a runner with a working Hyperlight hypervisor."
88+
finally:
89+
if sandbox is not None:
90+
_close_sandbox(sandbox)
8691

8792
return None
8893

@@ -146,6 +151,23 @@ def span_exporter(monkeypatch) -> Generator[InMemorySpanExporter]:
146151
exporter.clear()
147152

148153

154+
def _close_sandbox(sandbox: Any) -> None:
155+
close_hook = getattr(sandbox, "close", None) or getattr(sandbox, "shutdown", None)
156+
if callable(close_hook):
157+
with contextlib.suppress(Exception):
158+
close_hook()
159+
160+
161+
def _close_execute_code_registry(execute_code: HyperlightExecuteCodeTool) -> None:
162+
close_hook = getattr(execute_code._registry, "close", None)
163+
if callable(close_hook):
164+
close_hook()
165+
166+
167+
def _close_provider_registry(provider: HyperlightCodeActProvider) -> None:
168+
_close_execute_code_registry(provider._execute_code_tool)
169+
170+
149171
@pytest.fixture(scope="module")
150172
def shared_sandbox():
151173
"""Long-lived sandbox with snapshot/restore for read-mostly tests.
@@ -163,7 +185,10 @@ def shared_sandbox():
163185
)
164186
sandbox.run("None")
165187
snapshot = sandbox.snapshot()
166-
yield sandbox, snapshot
188+
try:
189+
yield sandbox, snapshot
190+
finally:
191+
_close_sandbox(sandbox)
167192

168193

169194
@pytest.fixture
@@ -190,7 +215,10 @@ def fresh_sandbox():
190215
module=execute_code_module.DEFAULT_HYPERLIGHT_MODULE,
191216
temp_output=True,
192217
)
193-
yield sandbox
218+
try:
219+
yield sandbox
220+
finally:
221+
_close_sandbox(sandbox)
194222

195223

196224
@tool(approval_mode="never_require")
@@ -1233,10 +1261,13 @@ async def test_agent_runs_hyperlight_codeact_end_to_end_with_fake_sandbox(monkey
12331261
provider = HyperlightCodeActProvider(tools=[compute])
12341262
agent = Agent(client=client, context_providers=[provider])
12351263

1236-
response = await agent.run("Use the sandbox to add 20 and 22.")
1264+
try:
1265+
response = await agent.run("Use the sandbox to add 20 and 22.")
12371266

1238-
assert response.text == "The sandbox returned 42."
1239-
assert client.call_count == 2
1267+
assert response.text == "The sandbox returned 42."
1268+
assert client.call_count == 2
1269+
finally:
1270+
_close_provider_registry(provider)
12401271
assert len(_FakeSandbox.instances) == 1
12411272
assert "compute" in _FakeSandbox.instances[0].registered_tools
12421273

@@ -1250,10 +1281,13 @@ async def test_agent_runs_hyperlight_codeact_end_to_end_with_real_sandbox() -> N
12501281
provider = HyperlightCodeActProvider(tools=[compute])
12511282
agent = Agent(client=client, context_providers=[provider])
12521283

1253-
response = await agent.run("Use the sandbox to add 20 and 22.")
1284+
try:
1285+
response = await agent.run("Use the sandbox to add 20 and 22.")
12541286

1255-
assert response.text == "The sandbox returned 42."
1256-
assert client.call_count == 2
1287+
assert response.text == "The sandbox returned 42."
1288+
assert client.call_count == 2
1289+
finally:
1290+
_close_provider_registry(provider)
12571291

12581292

12591293
@pytest.mark.integration
@@ -1272,41 +1306,44 @@ async def test_provider_run_tool_writes_files_with_real_sandbox(tmp_path: Path)
12721306
run_tool = context.tools[0][1][0]
12731307
assert isinstance(run_tool, HyperlightExecuteCodeTool)
12741308

1275-
result = await run_tool.invoke(
1276-
arguments={
1277-
"code": (
1278-
'payload = "hello from sandbox"\n'
1279-
"output_path = None\n"
1280-
'for candidate in ("/output/result.txt",):\n'
1281-
" try:\n"
1282-
' with open(candidate, "w", encoding="utf-8") as f:\n'
1283-
" f.write(payload)\n"
1284-
" except OSError:\n"
1285-
" continue\n"
1286-
" output_path = candidate\n"
1287-
" break\n"
1288-
'assert output_path is not None, "output path unavailable"\n'
1289-
'print("validated")\n'
1290-
)
1291-
}
1292-
)
1293-
1294-
outputs = result
1295-
error_outputs = [
1296-
f"{item.message}: {item.error_details}"
1297-
for item in outputs
1298-
if item.type == "error" and item.error_details is not None
1299-
]
1300-
assert not error_outputs, error_outputs
1301-
1302-
text_output = next((item for item in outputs if item.type == "text" and item.text is not None), None)
1303-
if text_output is not None:
1304-
assert text_output.text == "validated\n"
1309+
try:
1310+
result = await run_tool.invoke(
1311+
arguments={
1312+
"code": (
1313+
'payload = "hello from sandbox"\n'
1314+
"output_path = None\n"
1315+
'for candidate in ("/output/result.txt",):\n'
1316+
" try:\n"
1317+
' with open(candidate, "w", encoding="utf-8") as f:\n'
1318+
" f.write(payload)\n"
1319+
" except OSError:\n"
1320+
" continue\n"
1321+
" output_path = candidate\n"
1322+
" break\n"
1323+
'assert output_path is not None, "output path unavailable"\n'
1324+
'print("validated")\n'
1325+
)
1326+
}
1327+
)
13051328

1306-
file_output = next((item for item in outputs if item.type == "data"), None)
1307-
if file_output is not None:
1308-
assert file_output.uri is not None and file_output.uri.startswith("data:")
1309-
assert file_output.additional_properties["path"] in {"/output/result.txt", "/output/output/result.txt"}
1329+
outputs = result
1330+
error_outputs = [
1331+
f"{item.message}: {item.error_details}"
1332+
for item in outputs
1333+
if item.type == "error" and item.error_details is not None
1334+
]
1335+
assert not error_outputs, error_outputs
1336+
1337+
text_output = next((item for item in outputs if item.type == "text" and item.text is not None), None)
1338+
if text_output is not None:
1339+
assert text_output.text == "validated\n"
1340+
1341+
file_output = next((item for item in outputs if item.type == "data"), None)
1342+
if file_output is not None:
1343+
assert file_output.uri is not None and file_output.uri.startswith("data:")
1344+
assert file_output.additional_properties["path"] in {"/output/result.txt", "/output/output/result.txt"}
1345+
finally:
1346+
_close_execute_code_registry(run_tool)
13101347

13111348

13121349
@pytest.mark.integration
@@ -1325,46 +1362,49 @@ async def test_provider_run_tool_pings_bing_with_real_sandbox() -> None:
13251362
run_tool = context.tools[0][1][0]
13261363
assert isinstance(run_tool, HyperlightExecuteCodeTool)
13271364

1328-
result = await run_tool.invoke(
1329-
arguments={
1330-
"code": (
1331-
"import _socket\n\n"
1332-
'addresses = _socket.getaddrinfo("bing.com", 80, _socket.AF_INET, _socket.SOCK_STREAM)\n'
1333-
'assert addresses, "bing.com did not resolve"\n'
1334-
"last_error = None\n"
1335-
"for family, socktype, proto, _, sockaddr in addresses:\n"
1336-
" connection = None\n"
1337-
" try:\n"
1338-
" connection = _socket.socket(family, socktype, proto)\n"
1339-
" connection.settimeout(10)\n"
1340-
" connection.connect(sockaddr)\n"
1341-
' print("pinged bing.com")\n'
1342-
" break\n"
1343-
" except OSError as exc:\n"
1344-
" last_error = exc\n"
1345-
" finally:\n"
1346-
" if connection is not None:\n"
1347-
" try:\n"
1348-
" connection.close()\n"
1349-
" except OSError:\n"
1350-
" pass\n"
1351-
"else:\n"
1352-
' raise last_error or RuntimeError("unable to reach bing.com")\n'
1353-
)
1354-
}
1355-
)
1356-
1357-
outputs = result
1358-
error_outputs = [
1359-
f"{item.message}: {item.error_details}"
1360-
for item in outputs
1361-
if item.type == "error" and item.error_details is not None
1362-
]
1363-
assert not error_outputs, error_outputs
1365+
try:
1366+
result = await run_tool.invoke(
1367+
arguments={
1368+
"code": (
1369+
"import _socket\n\n"
1370+
'addresses = _socket.getaddrinfo("bing.com", 80, _socket.AF_INET, _socket.SOCK_STREAM)\n'
1371+
'assert addresses, "bing.com did not resolve"\n'
1372+
"last_error = None\n"
1373+
"for family, socktype, proto, _, sockaddr in addresses:\n"
1374+
" connection = None\n"
1375+
" try:\n"
1376+
" connection = _socket.socket(family, socktype, proto)\n"
1377+
" connection.settimeout(10)\n"
1378+
" connection.connect(sockaddr)\n"
1379+
' print("pinged bing.com")\n'
1380+
" break\n"
1381+
" except OSError as exc:\n"
1382+
" last_error = exc\n"
1383+
" finally:\n"
1384+
" if connection is not None:\n"
1385+
" try:\n"
1386+
" connection.close()\n"
1387+
" except OSError:\n"
1388+
" pass\n"
1389+
"else:\n"
1390+
' raise last_error or RuntimeError("unable to reach bing.com")\n'
1391+
)
1392+
}
1393+
)
13641394

1365-
text_output = next((item for item in outputs if item.type == "text" and item.text is not None), None)
1366-
if text_output is not None:
1367-
assert text_output.text == "pinged bing.com\n"
1395+
outputs = result
1396+
error_outputs = [
1397+
f"{item.message}: {item.error_details}"
1398+
for item in outputs
1399+
if item.type == "error" and item.error_details is not None
1400+
]
1401+
assert not error_outputs, error_outputs
1402+
1403+
text_output = next((item for item in outputs if item.type == "text" and item.text is not None), None)
1404+
if text_output is not None:
1405+
assert text_output.text == "pinged bing.com\n"
1406+
finally:
1407+
_close_execute_code_registry(run_tool)
13681408

13691409

13701410
# ---------------------------------------------------------------------------
@@ -1475,25 +1515,29 @@ async def test_output_dir_cleared_between_invocations() -> None:
14751515
run_tool = context.tools[0][1][0]
14761516
assert isinstance(run_tool, HyperlightExecuteCodeTool)
14771517

1478-
# First invocation: write a file
1479-
result1 = await run_tool.invoke(
1480-
arguments={"code": ('with open("/output/stale.txt", "w") as f:\n f.write("first")\nprint("wrote")\n')}
1481-
)
1482-
assert result1[0].type == "text" or result1[0].type == "data"
1483-
outputs1 = result1
1484-
assert any(
1485-
item.type == "data" and "stale.txt" in (item.additional_properties or {}).get("path", "") for item in outputs1
1486-
), "First invocation should produce stale.txt"
1487-
1488-
# Second invocation: no file writes
1489-
result2 = await run_tool.invoke(arguments={"code": 'print("clean")\n'})
1490-
outputs2 = result2
1491-
stale_files = [
1492-
item
1493-
for item in outputs2
1494-
if item.type == "data" and "stale.txt" in (item.additional_properties or {}).get("path", "")
1495-
]
1496-
assert not stale_files, "Stale output file leaked into second invocation"
1518+
try:
1519+
# First invocation: write a file
1520+
result1 = await run_tool.invoke(
1521+
arguments={"code": ('with open("/output/stale.txt", "w") as f:\n f.write("first")\nprint("wrote")\n')}
1522+
)
1523+
assert result1[0].type == "text" or result1[0].type == "data"
1524+
outputs1 = result1
1525+
assert any(
1526+
item.type == "data" and "stale.txt" in (item.additional_properties or {}).get("path", "")
1527+
for item in outputs1
1528+
), "First invocation should produce stale.txt"
1529+
1530+
# Second invocation: no file writes
1531+
result2 = await run_tool.invoke(arguments={"code": 'print("clean")\n'})
1532+
outputs2 = result2
1533+
stale_files = [
1534+
item
1535+
for item in outputs2
1536+
if item.type == "data" and "stale.txt" in (item.additional_properties or {}).get("path", "")
1537+
]
1538+
assert not stale_files, "Stale output file leaked into second invocation"
1539+
finally:
1540+
_close_execute_code_registry(run_tool)
14971541

14981542

14991543
@pytest.mark.integration
@@ -1532,12 +1576,16 @@ async def _concurrent_task():
15321576
concurrent_ran = True
15331577
release.set()
15341578

1535-
code_task = asyncio.create_task(run_tool.invoke(arguments={"code": 'print("done")\n'}))
1536-
await _concurrent_task()
1537-
result = await code_task
1579+
try:
1580+
code_task = asyncio.create_task(run_tool.invoke(arguments={"code": 'print("done")\n'}))
1581+
await _concurrent_task()
1582+
result = await code_task
15381583

1539-
assert concurrent_ran, "Event loop was blocked during sandbox execution"
1540-
assert result[0].type == "text"
1584+
assert concurrent_ran, "Event loop was blocked during sandbox execution"
1585+
assert result[0].type == "text"
1586+
finally:
1587+
release.set()
1588+
_close_execute_code_registry(run_tool)
15411589

15421590

15431591
class _ThreadAffinityFakeSandbox(_FakeSandbox):

0 commit comments

Comments
 (0)