fix(computer_use): fall back to CLI transport when cua-driver MCP bridge hits EAGAIN

The cua-driver MCP stdio bridge intermittently (and on some machines
persistently) fails to forward heavier calls like get_window_state to
the daemon with POSIX EAGAIN — 'daemon transport error forwarding
get_window_state: Resource temporarily unavailable (os error 35)'.
The wrapper surfaced this as an empty 0x0 capture, so computer_use
returned blank screenshots even though the display, permissions, and
the daemon were all healthy (the direct 'cua-driver call' CLI path
worked fine throughout).

Fix: when the MCP path raises the transient/transport error, fall back
to the 'cua-driver call' subprocess transport, which talks to the
daemon over a different socket. The CLI fallback routes get_window_state
screenshots to a temp file via screenshot_out_file (tiny JSON response
instead of a multi-MB base64 blob that congests the socket), reads the
PNG back, retries with backoff, and remaps the JSON into the same
{data, images, structuredContent, isError} shape the MCP path produces
so capture()/_action() are transport-agnostic.

Adds _is_transient_daemon_error() classifier and 3 regression tests.
Verified live: captures that returned 0x0 now return full
1567x905 screenshots with the AX element tree.
This commit is contained in:
Adrian Lastra 2026-06-07 11:33:54 -04:00 • committed by Teknium
parent de4310c8f6
commit 7af9abd174
2 changed files with 241 additions and 0 deletions

View file

@ -1468,6 +1468,103 @@ class TestCuaDriverSessionReconnect:
# Exactly one attempt, no reconnect.
assert len(bridge.calls) == 1
def test_is_transient_daemon_error_matches_eagain(self):
"""The EAGAIN daemon-proxy error must be classified as transient."""
from tools.computer_use.cua_backend import _CuaDriverSession
msg = ("daemon transport error forwarding `get_window_state`: "
"Resource temporarily unavailable (os error 35)")
assert _CuaDriverSession._is_transient_daemon_error(RuntimeError(msg)) is True
assert _CuaDriverSession._is_transient_daemon_error(
RuntimeError("daemon proxy to /tmp/sock not ready")) is True
# Unrelated errors must NOT be treated as transient.
assert _CuaDriverSession._is_transient_daemon_error(ValueError("boom")) is False
def test_call_tool_falls_back_to_cli_on_transient_error(self):
"""When the MCP bridge throws EAGAIN, call_tool routes to the CLI transport."""
import threading
from typing import Any, cast
from tools.computer_use.cua_backend import _CuaDriverSession
eagain = RuntimeError(
"daemon transport error forwarding `get_window_state`: "
"Resource temporarily unavailable (os error 35)"
)
class FakeBridge:
def __init__(self):
self.calls = []
def run(self, value, timeout=None):
self.calls.append((value, timeout))
raise eagain
bridge = FakeBridge()
session = cast(Any, _CuaDriverSession.__new__(_CuaDriverSession))
session._bridge = bridge
session._session = object()
session._exit_stack = None
session._lock = threading.Lock()
session._started = True
session._call_tool_async = lambda name, args: ("call", name, args)
cli_calls = []
def fake_cli(name, args, timeout):
cli_calls.append((name, args))
return {"data": "42 elements\ntree", "images": ["B64PNG"],
"structuredContent": {"element_count": 42}, "isError": False}
session._call_tool_via_cli = fake_cli
result = session.call_tool("get_window_state", {"pid": 1, "window_id": 2})
# MCP path attempted exactly once, then CLI fallback used.
assert len(bridge.calls) == 1
assert cli_calls == [("get_window_state", {"pid": 1, "window_id": 2})]
assert result["images"] == ["B64PNG"]
def test_cli_fallback_reads_screenshot_from_file(self, tmp_path):
"""_call_tool_via_cli must base64-read a screenshot written to disk
(screenshot_out_file path) when no inline base64 is present."""
import base64 as _b64
from typing import Any, cast
from tools.computer_use.cua_backend import _CuaDriverSession
png_bytes = b"\x89PNG\r\n\x1a\nFAKEDATA"
shot = tmp_path / "shot.png"
shot.write_bytes(png_bytes)
session = cast(Any, _CuaDriverSession.__new__(_CuaDriverSession))
captured_cmd = {}
class FakeProc:
returncode = 0
stderr = ""
# Daemon returns a path, not inline base64.
stdout = ('{"element_count": 7, "tree_markdown": "- [0] AXButton",'
' "screenshot_file_path": "%s"}' % str(shot))
import subprocess as _sp
orig_run = _sp.run
def fake_run(cmd, **kw):
captured_cmd["cmd"] = cmd
return FakeProc()
_sp.run = fake_run
try:
out = session._call_tool_via_cli("get_window_state",
{"pid": 1, "window_id": 2}, 30.0)
finally:
_sp.run = orig_run
# Screenshot read from disk and base64-encoded.
assert out["images"] == [_b64.b64encode(png_bytes).decode("ascii")]
# tree_markdown surfaced as the data text blob with the element-count summary.
assert "AXButton" in out["data"]
assert "7 elements" in out["data"]
class TestCaptureAppFilterNoMatch:
"""capture(app=X) must not silently fall back to the frontmost window

View file

@ -814,6 +814,31 @@ class _CuaDriverSession:
or isinstance(exc, (BrokenPipeError, EOFError))
)
@staticmethod
def _is_transient_daemon_error(exc: Exception) -> bool:
"""Return True for the cua-driver daemon-proxy EAGAIN congestion error.
On macOS the ``cua-driver mcp`` bridge forwards calls to the CuaDriver
daemon over a non-blocking unix socket. Heavier ops (notably
``get_window_state``, which walks the AX tree and captures a PNG) can
come back as an ``McpError`` carrying ``Resource temporarily
unavailable (os error 35)`` — POSIX EAGAIN — when the socket buffer is
momentarily full. This is transient by definition: the same call
succeeds when retried after a short pause (which is why spaced-out
single calls work while rapid/large ones intermittently fail). Detect
it by message so we can retry with backoff rather than surfacing an
empty 0x0 capture to the model. See the EAGAIN diagnosis in
references/catalog-add-troubleshooting (apple-music skill) and the
cua-driver daemon-proxy note.
"""
msg = str(exc)
return (
"Resource temporarily unavailable" in msg
or "os error 35" in msg
or "daemon transport error" in msg
or "daemon proxy" in msg
)
def _restart_session_locked(self) -> None:
"""Recreate the MCP session after the daemon/stdin transport was closed.
Caller must hold self._lock (the reconnect-once retry path holds it)."""
@ -829,11 +854,130 @@ class _CuaDriverSession:
self._start_lifecycle_locked()
self._started = True
def _call_tool_via_cli(self, name: str, args: Dict[str, Any], timeout: float) -> Dict[str, Any]:
"""Fallback transport: invoke ``cua-driver call <tool> <json>`` as a
subprocess instead of going through the stdio MCP bridge.
The ``cua-driver mcp`` stdio bridge can persistently fail to forward
heavier calls (notably ``get_window_state``) to the daemon with POSIX
EAGAIN, while the plain ``cua-driver call`` path — which talks to the
daemon over its own socket — keeps working. When the MCP path gives up,
we retry over the CLI and remap the JSON into the same dict shape that
``_extract_tool_result`` produces, so callers (capture(), _action(),
list_windows parsing) are transport-agnostic.
For ``get_window_state`` we route the screenshot to a temp file via
``screenshot_out_file`` so the daemon returns a tiny JSON body (a path)
instead of a multi-megabyte base64 blob — the large payload is what
congests the daemon socket and triggers EAGAIN in the first place. We
read the PNG back from disk and base64-encode it ourselves. The CLI
call is itself retried a few times with backoff, since the underlying
daemon socket can still be momentarily busy.
"""
import subprocess as _subprocess
import tempfile as _tempfile
import time as _time
call_args = dict(args)
shot_file: Optional[str] = None
if name == "get_window_state" and "screenshot_out_file" not in call_args:
fd, shot_file = _tempfile.mkstemp(prefix="cua_shot_", suffix=".png")
os.close(fd)
call_args["screenshot_out_file"] = shot_file
cmd = [_CUA_DRIVER_CMD, "call", name, json.dumps(call_args)]
attempts = 4
backoff = 0.5
parsed: Any = None
last_err = ""
try:
for attempt in range(attempts):
try:
proc = _subprocess.run(
cmd, capture_output=True, text=True, timeout=max(15.0, timeout)
)
except Exception as e: # pragma: no cover - subprocess spawn failure
raise RuntimeError(f"cua-driver CLI fallback for {name} failed to spawn: {e}") from e
out = (proc.stdout or "").strip()
last_err = out[:200] or (proc.stderr or "")[:200]
start = min(
(i for i in (out.find("{"), out.find("[")) if i != -1),
default=-1,
)
if start != -1:
try:
candidate = json.loads(out[start:])
except json.JSONDecodeError:
candidate = None
if candidate is not None:
parsed = candidate
break
# No JSON (EAGAIN warning / empty) — retry with backoff.
if attempt < attempts - 1:
logger.warning(
"cua-driver CLI fallback for %s got no JSON "
"(attempt %d/%d); retrying in %.1fs",
name, attempt + 1, attempts, backoff,
)
_time.sleep(backoff)
backoff *= 2
if parsed is None:
raise RuntimeError(
f"cua-driver CLI fallback for {name} returned no JSON after "
f"{attempts} attempts: {last_err}"
)
# Remap structured JSON into {data, images, structuredContent, isError}.
images: List[str] = []
data: Any = None
structured: Optional[Dict] = parsed if isinstance(parsed, dict) else None
if isinstance(parsed, dict):
shot = parsed.get("screenshot_png_b64")
if not shot:
# Screenshot was routed to a file (ours or the daemon's choice).
fpath = parsed.get("screenshot_file_path") or shot_file
if fpath and os.path.exists(fpath):
try:
with open(fpath, "rb") as fh:
shot = base64.b64encode(fh.read()).decode("ascii")
except Exception as e:
logger.debug("cua-driver CLI fallback: failed reading %s: %s", fpath, e)
if shot:
images.append(shot)
tree = parsed.get("tree_markdown")
if tree is not None:
ec = parsed.get("element_count")
summary = f"{ec} elements" if ec is not None else ""
data = f"{summary}\n{tree}" if summary else tree
return {"data": data, "images": images, "structuredContent": structured, "isError": False}
finally:
if shot_file and os.path.exists(shot_file):
try:
os.remove(shot_file)
except OSError:
pass
def call_tool(self, name: str, args: Dict[str, Any], timeout: float = 30.0) -> Dict[str, Any]:
self._require_started()
# The cua-driver daemon proxy returns POSIX EAGAIN ("Resource
# temporarily unavailable") for heavier calls like get_window_state when
# its non-blocking socket buffer is full. On some machines/builds this
# is persistent for get_window_state over the MCP stdio bridge, while
# the direct CLI transport keeps working. So: try the MCP path ONCE,
# and on the transient/transport error fall straight through to the CLI
# transport (which has its own retry + screenshot-to-file mitigation)
# rather than burning a long backoff chain on a path that won't recover.
try:
return self._bridge.run(self._call_tool_async(name, args), timeout=timeout)
except Exception as e:
if self._is_transient_daemon_error(e):
logger.warning(
"cua-driver MCP transport failed on %s (%s); "
"falling back to CLI transport", name, e,
)
return self._call_tool_via_cli(name, args, timeout)
if not self._is_closed_session_error(e):
raise
# Daemon restart closes the cached stdio channel. Reconnect once and