Skip to content

Commit fe83572

Browse files
authored
fix(sandbox): clean up PTY startup cancellation (#4750)
1 parent b4e6020 commit fe83572

6 files changed

Lines changed: 261 additions & 4 deletions

File tree

src/agents/extensions/sandbox/blaxel/sandbox.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
PTY_PROCESSES_MAX,
5252
PTY_PROCESSES_WARNING,
5353
PtyExecUpdate,
54+
_settle_pty_cleanup,
5455
allocate_pty_process_id,
5556
clamp_pty_yield_time_ms,
5657
process_id_to_prune_from_meta,
@@ -839,11 +840,18 @@ async def pty_exec_start(
839840
registered = True
840841
except asyncio.TimeoutError as e:
841842
if not registered:
842-
await self._terminate_pty_entry(entry)
843+
await _settle_pty_cleanup(self._terminate_pty_entry(entry))
843844
raise ExecTimeoutError(command=command, timeout_s=exec_timeout, cause=e) from e
845+
except asyncio.CancelledError as cancellation:
846+
if not registered:
847+
await _settle_pty_cleanup(
848+
self._terminate_pty_entry(entry),
849+
initial_cancellation=cancellation,
850+
)
851+
raise
844852
except Exception as e:
845853
if not registered:
846-
await self._terminate_pty_entry(entry)
854+
await _settle_pty_cleanup(self._terminate_pty_entry(entry))
847855
raise _blaxel_exec_transport_error(command=command, cause=e) from e
848856

849857
if pruned is not None:

src/agents/sandbox/sandboxes/unix_local.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ def _preexec() -> None:
354354
env=env,
355355
preexec_fn=_preexec,
356356
)
357-
except Exception:
357+
except BaseException:
358358
with suppress(OSError):
359359
os.close(primary_fd)
360360
with suppress(OSError):

src/agents/sandbox/session/pty_types.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ import annotations
22

3+
import asyncio
34
import random
4-
from collections.abc import Sequence
5+
from collections.abc import Awaitable, Sequence
56
from dataclasses import dataclass
67

78
from ..util.token_truncation import formatted_truncate_text_with_token_count
@@ -18,6 +19,32 @@
1819
PTY_PROCESS_ID_MAX_EXCLUSIVE = 100_000
1920

2021

22+
async def _settle_pty_cleanup(
23+
cleanup: Awaitable[None],
24+
*,
25+
initial_cancellation: asyncio.CancelledError | None = None,
26+
) -> None:
27+
cleanup_task = asyncio.ensure_future(cleanup)
28+
completion = asyncio.create_task(asyncio.wait((cleanup_task,)))
29+
cancellation = initial_cancellation
30+
while not completion.done():
31+
try:
32+
await asyncio.shield(completion)
33+
except asyncio.CancelledError as error:
34+
if cancellation is None:
35+
cancellation = error
36+
37+
completion.result()
38+
try:
39+
cleanup_task.result()
40+
except BaseException:
41+
if cancellation is not None:
42+
raise cancellation from None
43+
raise
44+
if cancellation is not None:
45+
raise cancellation from None
46+
47+
2148
@dataclass(frozen=True)
2249
class PtyExecUpdate:
2350
process_id: int | None

tests/extensions/sandbox/test_blaxel.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import json
66
import logging
77
import shlex
8+
import sys
89
import tarfile
910
import time
1011
import uuid
@@ -1745,6 +1746,157 @@ def ClientSession(self) -> _FakeHTTPSession:
17451746

17461747

17471748
class TestPtyExec:
1749+
@pytest.mark.asyncio
1750+
async def test_pty_exec_start_cancellation_closes_unregistered_http_session(
1751+
self, fake_sandbox: _FakeSandboxInstance
1752+
) -> None:
1753+
from agents.extensions.sandbox.blaxel import sandbox as mod
1754+
1755+
connect_started = asyncio.Event()
1756+
1757+
class _BlockingSession:
1758+
def __init__(self) -> None:
1759+
self._closed = False
1760+
1761+
async def ws_connect(self, url: str) -> None:
1762+
_ = url
1763+
connect_started.set()
1764+
await asyncio.Event().wait()
1765+
1766+
async def close(self) -> None:
1767+
self._closed = True
1768+
1769+
class _BlockingAiohttp:
1770+
WSMsgType = _FakeAiohttp.WSMsgType
1771+
1772+
def __init__(self) -> None:
1773+
self.session: _BlockingSession | None = None
1774+
1775+
def ClientSession(self) -> _BlockingSession:
1776+
self.session = _BlockingSession()
1777+
return self.session
1778+
1779+
fake_aiohttp = _BlockingAiohttp()
1780+
session = _make_session(fake_sandbox)
1781+
1782+
with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp):
1783+
task = asyncio.create_task(session.pty_exec_start("echo", "hello"))
1784+
await connect_started.wait()
1785+
task.cancel("connect-cancel")
1786+
1787+
with pytest.raises(asyncio.CancelledError) as exc_info:
1788+
await task
1789+
1790+
if sys.version_info >= (3, 11):
1791+
assert exc_info.value.args == ("connect-cancel",)
1792+
assert task.cancelled()
1793+
1794+
assert fake_aiohttp.session is not None
1795+
assert fake_aiohttp.session._closed
1796+
assert session._pty_sessions == {}
1797+
assert session._reserved_pty_process_ids == set()
1798+
1799+
@pytest.mark.asyncio
1800+
async def test_pty_exec_start_preserves_cancellation_during_cleanup(
1801+
self, fake_sandbox: _FakeSandboxInstance
1802+
) -> None:
1803+
from agents.extensions.sandbox.blaxel import sandbox as mod
1804+
1805+
cleanup_started = asyncio.Event()
1806+
allow_cleanup = asyncio.Event()
1807+
1808+
class _TimeoutSession:
1809+
def __init__(self) -> None:
1810+
self._closed = False
1811+
1812+
async def ws_connect(self, url: str) -> None:
1813+
_ = url
1814+
raise asyncio.TimeoutError()
1815+
1816+
async def close(self) -> None:
1817+
cleanup_started.set()
1818+
await allow_cleanup.wait()
1819+
self._closed = True
1820+
1821+
class _TimeoutAiohttp:
1822+
WSMsgType = _FakeAiohttp.WSMsgType
1823+
1824+
def __init__(self) -> None:
1825+
self.session: _TimeoutSession | None = None
1826+
1827+
def ClientSession(self) -> _TimeoutSession:
1828+
self.session = _TimeoutSession()
1829+
return self.session
1830+
1831+
fake_aiohttp = _TimeoutAiohttp()
1832+
session = _make_session(fake_sandbox)
1833+
1834+
with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp):
1835+
task = asyncio.create_task(session.pty_exec_start("echo", "hello"))
1836+
await cleanup_started.wait()
1837+
task.cancel("cleanup-cancel")
1838+
allow_cleanup.set()
1839+
1840+
with pytest.raises(asyncio.CancelledError) as exc_info:
1841+
await task
1842+
1843+
if sys.version_info >= (3, 11):
1844+
assert exc_info.value.args == ("cleanup-cancel",)
1845+
assert fake_aiohttp.session is not None
1846+
assert fake_aiohttp.session._closed
1847+
assert session._pty_sessions == {}
1848+
assert session._reserved_pty_process_ids == set()
1849+
1850+
@pytest.mark.asyncio
1851+
async def test_pty_exec_start_preserves_cancellation_when_cleanup_fails(
1852+
self, fake_sandbox: _FakeSandboxInstance
1853+
) -> None:
1854+
from agents.extensions.sandbox.blaxel import sandbox as mod
1855+
1856+
cleanup_started = asyncio.Event()
1857+
allow_cleanup = asyncio.Event()
1858+
1859+
class _FailingCleanupSession:
1860+
def __init__(self) -> None:
1861+
self._closed = False
1862+
1863+
async def ws_connect(self, url: str) -> None:
1864+
_ = url
1865+
raise asyncio.TimeoutError()
1866+
1867+
async def close(self) -> None:
1868+
cleanup_started.set()
1869+
await allow_cleanup.wait()
1870+
self._closed = True
1871+
raise RuntimeError("synthetic cleanup failure")
1872+
1873+
class _FailingCleanupAiohttp:
1874+
WSMsgType = _FakeAiohttp.WSMsgType
1875+
1876+
def __init__(self) -> None:
1877+
self.session: _FailingCleanupSession | None = None
1878+
1879+
def ClientSession(self) -> _FailingCleanupSession:
1880+
self.session = _FailingCleanupSession()
1881+
return self.session
1882+
1883+
fake_aiohttp = _FailingCleanupAiohttp()
1884+
session = _make_session(fake_sandbox)
1885+
1886+
with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp):
1887+
task = asyncio.create_task(session.pty_exec_start("echo", "hello"))
1888+
await cleanup_started.wait()
1889+
task.cancel()
1890+
allow_cleanup.set()
1891+
1892+
with pytest.raises(asyncio.CancelledError):
1893+
await task
1894+
1895+
assert fake_aiohttp.session is not None
1896+
assert fake_aiohttp.session._closed
1897+
assert session._pty_sessions == {}
1898+
assert session._reserved_pty_process_ids == set()
1899+
17481900
@pytest.mark.parametrize(
17491901
("messages", "expected_output"),
17501902
[

tests/sandbox/test_pty_types.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
from __future__ import annotations
22

3+
import asyncio
4+
import sys
5+
6+
import pytest
7+
38
from agents.sandbox.session.pty_types import (
49
PTY_EMPTY_YIELD_TIME_MS_MIN,
510
PTY_YIELD_TIME_MS_MIN,
11+
_settle_pty_cleanup,
612
allocate_pty_process_id,
713
clamp_pty_yield_time_ms,
814
process_id_to_prune_from_meta,
@@ -37,3 +43,39 @@ def test_process_id_to_prune_from_meta_prefers_exited_unprotected_sessions() ->
3743
meta.append((2002, 2.0, False))
3844

3945
assert process_id_to_prune_from_meta(meta) == 2001
46+
47+
48+
@pytest.mark.asyncio
49+
async def test_settle_pty_cleanup_preserves_cancel_reason_when_cleanup_fails() -> None:
50+
cleanup_started = asyncio.Event()
51+
cleanup_release = asyncio.Event()
52+
53+
async def cleanup() -> None:
54+
cleanup_started.set()
55+
await cleanup_release.wait()
56+
raise RuntimeError("synthetic cleanup failure")
57+
58+
task = asyncio.create_task(_settle_pty_cleanup(cleanup()))
59+
await cleanup_started.wait()
60+
task.cancel("route-A")
61+
cleanup_release.set()
62+
63+
with pytest.raises(asyncio.CancelledError) as exc_info:
64+
await task
65+
66+
if sys.version_info >= (3, 11):
67+
assert exc_info.value.args == ("route-A",)
68+
69+
70+
@pytest.mark.asyncio
71+
async def test_settle_pty_cleanup_preserves_initial_cancel_reason_when_cleanup_fails() -> None:
72+
async def cleanup() -> None:
73+
raise RuntimeError("synthetic cleanup failure")
74+
75+
with pytest.raises(asyncio.CancelledError) as exc_info:
76+
await _settle_pty_cleanup(
77+
cleanup(),
78+
initial_cancellation=asyncio.CancelledError("startup-cancel"),
79+
)
80+
81+
assert exc_info.value.args == ("startup-cancel",)

tests/sandbox/test_unix_local.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,34 @@ def _unexpected_mkdtemp(*args: object, **kwargs: object) -> str:
216216

217217
@pytest.mark.review_optional
218218
class TestUnixLocalPty:
219+
@pytest.mark.asyncio
220+
async def test_tty_start_cancellation_closes_open_file_descriptors(
221+
self,
222+
tmp_path: Path,
223+
monkeypatch: pytest.MonkeyPatch,
224+
) -> None:
225+
monkeypatch.setattr(unix_local_module.sys, "platform", "linux")
226+
workspace = tmp_path / "workspace"
227+
workspace.mkdir()
228+
session = _RecordingUnixLocalSession(workspace)
229+
close_calls: list[int] = []
230+
231+
def openpty() -> tuple[int, int]:
232+
return 101, 102
233+
234+
async def create_subprocess(*args: object, **kwargs: object) -> None:
235+
_ = (args, kwargs)
236+
raise asyncio.CancelledError()
237+
238+
monkeypatch.setattr(unix_local_module.os, "openpty", openpty)
239+
monkeypatch.setattr(unix_local_module.os, "close", close_calls.append)
240+
monkeypatch.setattr(unix_local_module.asyncio, "create_subprocess_exec", create_subprocess)
241+
242+
with pytest.raises(asyncio.CancelledError):
243+
await session.pty_exec_start("echo", "hello", shell=False, tty=True)
244+
245+
assert close_calls == [101, 102]
246+
219247
@pytest.mark.asyncio
220248
async def test_tty_fd_close_is_owned_without_blocking_termination(
221249
self,

0 commit comments

Comments
 (0)