Null pid/window_id guard in list_windows on Linux/X11
Cluster 56704 · agent. Data as of
Nothing open on GitHub. Fix merged in #58173. Check it covers your case.
The short answer
- Known?
- Yes. 5 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #58173. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple fixes and a bug report addressing a crash when list_windows returns None for pid or window_id on Linux, WSL, and X11 environments. The proposed solutions add guards or skip null-valued entries to prevent int(None) errors.
Open issues0
Open PRs0
Fixes on record
-
#58173 fix(computer-use): skip null-pid/window_id windows from list_windows (X11)
files:
scripts/release.py,tests/tools/test_computer_use_null_pid_windows.py,tools/computer_use/cua_backend.pyShow the diff
-
scripts/release.py@@ -261,6 +261,7 @@ "[redacted:email]": "synapsesx", "[redacted:email]": "itsflownium", "[redacted:email]": "dirtyren", + "[redacted:email]": "Marxb85", "[redacted:email]": "yyzquwu", # PR #47567 salvage (Matrix: register inbound handlers with wait_sync=True so _dispatch_sync's gather awaits them; without it mautrix fire-and-forgets and inbound intake has no completion point) "[redacted:email]": "jearnest11", # PR #48700 salvage (multi-profile gateway flap: use node symlink's own parent, not .resolve() target, when building systemd/launchd service PATH so one profile's node path can't leak into every unit and force a perpetual daemon-reload restart loop) "[redacted:email]": "tgmerritt", # PR #43553 salvage (parse vLLM's token-based output-cap error format so over-cap max_tokens 400s reduce the output cap instead of death-looping into compression) -
tests/tools/test_computer_use_null_pid_windows.py@@ -0,0 +1,144 @@ +"""Regression for the X11 null-PID `list_windows` crash. + +On X11 a window's PID comes from the *optional* ``_NET_WM_PID`` property, so +the cua-driver legitimately reports ``pid: null`` for windows that don't set +it (the desktop root, panels, override-redirect popups, …). Both +``capture()`` and ``focus_app()`` previously coerced *every* window's pid via +``int(w["pid"])`` inside a list comprehension, so a single null-pid window +raised:: + + TypeError: int() argument must be a string, a bytes-like object or a + real number, not 'NoneType' + +…aborting the whole enumeration before any screenshot was taken — i.e. the +agent could never capture the screen at all on an X11 desktop that had even +one such window. + +The fix routes both ingestion sites through ``_ingest_windows``, which skips +windows lacking a usable pid/window_id (uncapturable anyway) and coerces the +rest, so real targetable windows survive. +""" + +from __future__ import annotations + +import base64 +from unittest.mock import MagicMock + +# 8×8 transparent PNG — decodes cleanly so capture() can size it. +_PNG_B64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAADUlEQVR4nG" + "NgGAUgAAABCAABgukLHQAAAABJRU5ErkJggg==" +) + + +# --------------------------------------------------------------------------- +# _ingest_windows: the fix locus (pure function, no session needed) +# --------------------------------------------------------------------------- + +class TestIngestWindows: + def test_skips_window_with_null_pid(self): + from tools.computer_use.cua_backend import _ingest_windows + + raw = [ + {"app_name": "Desktop", "pid": None, "window_id": 1, "z_index": 0}, + {"app_name": "Firefox", "pid": 4321, "window_id": 77, "z_index": 1}, + ] + + out = _ingest_windows(raw) + + assert [w["app_name"] for w in out] == ["Firefox"] + assert out[0]["pid"] == 4321 + assert out[0]["window_id"] == 77 + + def test_skips_window_with_null_window_id(self): + from tools.computer_use.cua_backend import _ingest_windows + + raw = [ + {"app_name": "Panel", "pid": 10, "window_id": None, "z_index": 0}, + {"app_name": "Firefox", "pid": 4321, "window_id": 77, "z_index": 1}, + ] + + out = _ingest_windows(raw) + + assert [w["app_name"] for w in out] == ["Firefox"] + + def test_coerces_numeric_strings_like_the_original_int_call(self): + # The original `int(w["pid"])` accepted numeric strings; preserve that. + from tools.computer_use.cua_backend import _ingest_windows + + out = _ingest_windows( + [{"app_name": "Term", "pid": "200", "window_id": "9", "z_index": 0}] + ) + + assert out[0]["pid"] == 200 + assert out[0]["window_id"] == 9 + assert isinstance(out[0]["pid"], int) + + def test_preserves_fields_capture_relies_on(self): + from tools.computer_use.cua_backend import _ingest_windows + + out = _ingest_windows([ + { + "app_name": "Firefox", + "pid": 1, + "window_id": 2, + "is_on_screen": False, + "title": "Mozilla Firefox", + "z_index": 3, + } + ]) + + w = out[0] + assert w["off_screen"] is True # derived from is_on_screen + assert w["title"] == "Mozilla Firefox" + assert w["z_index"] == 3 + + +# --------------------------------------------------------------------------- +# capture(): end-to-end proof the null-pid window no longer crashes capture +# --------------------------------------------------------------------------- + +def _backend_with_windows(raw_windows): + """A CuaDriverBackend whose session returns `raw_windows` from + list_windows and a valid PNG from screenshot.""" + from tools.computer_use.cua_backend import CuaDriverBackend + + backend = CuaDriverBackend() + session = MagicMock() + session.capabilities_discovered = True + session._has_tool.return_value = True + + def _call_tool(name, args, *a, **k): + if name == "list_windows": + return {"structuredContent": {"windows": raw_windows}} + if name == "screenshot": + return { + "structuredContent": { + "screenshot_png_b64": _PNG_B64, + "screenshot_mime_type": "image/png", + } + } + return {} + + session.call_tool.side_effect = _call_tool + backend._session = session + return backend + + +def test_capture_vision_survives_null_pid_window(): + raw = [ + {"app_name": "Desktop", "pid": None, "window_id": 1, "z_index": 0}, + {"app_name": "Firefox", "pid": 4321, "window_id": 77, + "is_on_screen": True, "title": "Mozilla Firefox", "z_index": 1}, + ] + backend = _backend_with_windows(raw) + + cap = backend.capture(mode="vision") + + # The real, targetable window is selected rather than the whole capture + # crashing on the null-pid desktop window. + assert cap.app == "Firefox" + assert cap.png_b64 == _PNG_B64 + assert backend._active_pid == 4321 + assert backend._active_window_id == 77 + assert base64.b64decode(cap.png_b64) # decodes cleanly -
tools/computer_use/cua_backend.py@@ -909,6 +909,41 @@ def _image_from_tool_result(out: Dict[str, Any]) -> tuple[Optional[str], Optiona return None, None +def _ingest_windows(raw_windows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Normalise cua-driver ``list_windows`` entries, dropping unusable ones. + + Every downstream operation needs both an integer ``pid`` (for + get_window_state / action tools) and ``window_id`` (for screenshot / + element clicks), so a window missing either is uncapturable. + + Crucially, on X11 a window's PID comes from the *optional* + ``_NET_WM_PID`` property — the desktop root, panels, and + override-redirect popups routinely omit it, so the driver reports + ``pid: null`` for them. Coercing every entry unconditionally + (``int(w["pid"])``) let one such window abort enumeration of the real, + targetable windows. We skip the unusable entries instead so capture() + and focus_app() still find the windows that matter. + """ + windows: List[Dict[str, Any]] = [] + for w in raw_windows: + pid, window_id = w.get("pid"), w.get("window_id") + if pid is None or window_id is None: + continue + try: + pid_int, window_id_int = int(pid), int(window_id) + except (TypeError, ValueError): + continue + windows.append({ + "app_name": w.get("app_name", ""), + "pid": pid_int, + "window_id": window_id_int, + "off_screen": not w.get("is_on_screen", True), + "title": w.get("title", ""), + "z_index": w.get("z_index", 0), + }) + return windows + + # --------------------------------------------------------------------------- # The backend itself # --------------------------------------------------------------------------- @@ -1028,17 +1063,7 @@ def capture(self, mode: str = "som", app: Optional[str] = None) -> CaptureResult {"on_screen_only": True, "session": self._session_id}, ) raw_windows = (lw_out.get("structuredContent") or {}).get("windows") or [] - windows = [ - { - "app_name": w.get("app_name", ""), - "pid": int(w["pid"]), - "window_id": int(w["window_id"]), - "off_screen": not w.get("is_on_screen", True), - "title": w.get("title", ""), - "z_index": w.get("z_index", 0), - } - for w in raw_windows - ] + windows = _ingest_windows(raw_windows) # Sort by z_index descending (lowest z_index = frontmost on macOS). windows.sort(key=lambda w: w["z_index"]) @@ -1433,15 +1458,7 @@ def focus_app(self, app: str, raise_window: bool = False) -> ActionResult: {"on_screen_only": True, "session": self._session_id}, ) raw_windows = (lw_out.get("structuredContent") or {}).get("windows") or [] - windows = [ - { - "app_name": w.get("app_name", ""), - "pid": int(w["pid"]), - "window_id": int(w["window_id"]), - "z_index": w.get("z_index", 0), - } - for w in raw_windows - ] + windows = _ingest_windows(raw_windows) windows.sort(key=lambda w: w["z_index"]) app_lower = app.lower()
-
3 PRs closed without merging
From the close comments: superseded by another PR 2 · done elsewhere 1
-
#56707 fix(computer-use): guard against None pid/window_id from list_windows · superseded by another PR →
#52498 -
#57392 fix(computer-use): guard None pid/window_id in list_windows parse (WSLg/X11 compatibility) · done elsewhere →
#58173,#52498 -
#57426 fix(tools): guard computer_use against null pid/window_id from list_windows · superseded by another PR →
#52498,#57392
Closed issues · 1
- Closed#56704computer_use capture fails on Linux/WSL: int(None) on pid/window_id from list_windows
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.40: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- agent
- Merged PRs
- #58173
- Closed issues
- 1
- Attempts
- 3 PRs closed without merging
- Fix link checked
- 4 Jul 2026