Skip to content

UnixLocalSandbox blocks the event loop with synchronous filesystem and tar I/O #4675

Description

@rajarshidattapy

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 tree

Both 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: main at e773b15, and v0.22.0
  • Python version: 3.13
  • Operating system: Linux (the module raises ImportError on 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 task

The 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions