Repository navigation
Description
Please read this first
- Have you read the docs? Yes.
- Have you searched for related issues? Yes. Nothing covers blocking filesystem work in the sandbox backends.
Describe the bug
UnixLocalSandboxSession calls synchronous pathlib and tarfile APIs directly from async def methods, so those methods block the event loop for the full duration of the I/O instead of yielding.
The two unbounded ones archive or extract the entire workspace tree:
async def persist_workspace(self) -> io.IOBase:
root = Path(self.state.manifest.root)
if not root.exists(): # blocking stat
...
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as tar:
tar.add(root, arcname=".", filter=...) # blocking walk + read of the whole tree
async def hydrate_workspace(self, data: io.IOBase) -> None:
root = Path(self.state.manifest.root)
root.mkdir(parents=True, exist_ok=True)
with tarfile.open(fileobj=data, mode="r:*") as tar:
safe_extract_tarfile(tar, root=root, ...) # blocking extraction of the whole treeBoth sit directly on the snapshot path: persist_snapshot() awaits session.persist_workspace() and restore_snapshot_into_workspace_on_resume() awaits session.hydrate_workspace() (src/agents/sandbox/session/snapshot_lifecycle.py). A workspace holding a node_modules, a build directory, or a model checkpoint takes seconds to minutes to tar, and the whole application's loop is stalled for that window: concurrent agent runs, streamed responses, MCP sessions, and tracing exports all freeze. That rules the local sandbox out of any server process handling more than one run at a time.
Scope note (edited after review feedback): this report originally also listed the bounded mkdir, exists, and resolve calls in _prepare_backend_workspace, _resolved_exec_context, _exec_internal, pty_exec_start and _write_stream_with_exec. Those are possible cleanup candidates, but a static ASYNC240 report on its own does not establish a user-visible defect for them, and I have no measurement showing one. The defect claimed here is limited to the two unbounded archive paths above.
The pattern already exists in this file
This is a gap rather than a design decision: the same module already offloads its other blocking work, with loop.run_in_executor(None, os.read, ...) for PTY reads (line 517) and asyncio.to_thread(_close_fd_quietly, fd) for fd cleanup (line 616). sandbox/session/sinks.py uses asyncio.to_thread too. The filesystem and tar paths were simply missed.
Why CI does not catch it
ruff --select ASYNC reports the two archive methods (along with five bounded sites that this report does not claim as a defect):
src/agents/sandbox/sandboxes/unix_local.py:1079:16: ASYNC240 Async functions should not use pathlib.Path methods
src/agents/sandbox/sandboxes/unix_local.py:1110:13: ASYNC240
make lint stays green because pyproject.toml pins ruff==0.9.2 in the dev group, which predates the ASYNC240 rule. Reproducing the report needs a newer ruff (checked with 0.16.3).
Debug information
- Agents SDK version:
mainat e773b15, and v0.22.0 - Python version: 3.13
- Operating system: Linux (the module raises
ImportErroron Windows by design) - Model and model provider: n/a — no model or network involved
- Does the issue reproduce with the latest Agents SDK release? Yes, and on
main. - Does the issue occur consistently or intermittently? Consistently.
Repro steps
Any coroutine sharing the loop stops making progress for the duration of the archive:
import asyncio, time
async def heartbeat(stop):
last = time.monotonic()
worst = 0.0
while not stop.is_set():
await asyncio.sleep(0.05)
now = time.monotonic()
worst = max(worst, now - last)
last = now
print(f"worst loop stall: {worst:.2f}s")
async def main(session): # a started UnixLocalSandboxSession whose
stop = asyncio.Event() # workspace holds a few hundred MB
task = asyncio.create_task(heartbeat(stop))
await session.persist_workspace()
stop.set()
await taskThe reported stall tracks the tar duration, instead of staying near the 0.05s tick.
Expected behavior
These methods should not hold the event loop. Move the blocking bodies onto a worker thread, matching what the rest of the module already does.
That needs explicit cancellation ownership to be correct. asyncio.to_thread cannot interrupt the thread it starts, so a cancelled caller would return while the worker is still walking or writing the workspace — and callers act on that return immediately: restore_snapshot_into_workspace_on_resume closes the archive stream in a finally, and resume clears the workspace root. So these methods must wait for the worker to settle before reporting cancellation.
I have a fix with regression tests and will open a PR shortly.
Activity
seratch commentedon Aug 26, 2026
Thanks, the unbounded archive paths are a demonstrated issue.
persist_workspace()andhydrate_workspace()synchronously walk, read, or write the entire workspace from the async snapshot lifecycle, so a large workspace can stall unrelated work on the event loop.Our current direction is to keep the initial fix focused on those two operations. The bounded
mkdir,exists, andresolvecalls may be cleanup candidates, but anASYNC240report alone does not establish a meaningful user-visible defect for them.Any future fix also needs explicit cancellation ownership. Cancelling
await asyncio.to_thread(...)does not stop the worker thread, so these methods must not return or report cancellation while background work continues reading from or mutating the workspace. Deterministic coverage should verify both event-loop responsiveness and that cancellation waits for archive or extraction work to settle.rajarshidattapy commentedon Aug 26, 2026
@seratch got it, I'm modifying the existing PR I put for this.