Skip to content

Commit e8b1d30

Browse files
k4cper-gclaude
andcommitted
Fix Linux AT-SPI2 segfault: add GLib thread init and remove unsafe parallel capture
- Initialize GLib threading support in _init_atspi() before any D-Bus calls, preventing segfaults on get_desktop() (especially on Python 3.14+) - Replace thread-unsafe ThreadPoolExecutor multi-window capture with sequential walking, since GObject/D-Bus proxies are not safe across threads Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 35269c8 commit e8b1d30

File tree

1 file changed

+18
-56
lines changed

1 file changed

+18
-56
lines changed

cup/platforms/linux.py

Lines changed: 18 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
2. Batch-reads core properties per node (role, name, description, states,
1010
bounds, attributes, actions, value) in a single walk pass
1111
3. Xlib (via ctypes) for screen info and foreground window detection
12-
4. Parallel tree walking with ThreadPoolExecutor for multi-window captures
12+
4. Sequential tree walking for multi-window captures (AT-SPI2 is not thread-safe)
1313
"""
1414

1515
from __future__ import annotations
@@ -19,7 +19,6 @@
1919
import itertools
2020
import os
2121
import subprocess
22-
from concurrent.futures import ThreadPoolExecutor
2322
from typing import Any
2423

2524
from cup._base import PlatformAdapter
@@ -284,6 +283,15 @@ def _init_atspi():
284283
import gi
285284

286285
gi.require_version("Atspi", "2.0")
286+
287+
# Initialize GLib threading support before any D-Bus calls.
288+
# AT-SPI2 communicates over D-Bus (GLib-based); without this,
289+
# get_desktop() can segfault — especially on Python 3.14+.
290+
from gi.repository import GLib
291+
292+
if hasattr(GLib, "threads_init"):
293+
GLib.threads_init()
294+
287295
from gi.repository import Atspi
288296

289297
# Event listeners are not needed — we only read the tree.
@@ -942,73 +950,27 @@ def capture_tree(
942950
self.initialize()
943951
refs: dict[str, Any] = {}
944952

945-
if len(windows) <= 1:
946-
# Single window — sequential walk
947-
id_gen = itertools.count()
948-
stats: dict = {"nodes": 0, "max_depth": 0, "roles": {}}
949-
tree: list[dict] = []
950-
for win in windows:
951-
node = _build_cup_node(
952-
win["handle"],
953-
id_gen,
954-
stats,
955-
0,
956-
max_depth,
957-
self._screen_w,
958-
self._screen_h,
959-
refs,
960-
)
961-
if node is not None:
962-
tree.append(node)
963-
return tree, stats, refs
964-
else:
965-
# Multiple windows — parallel walk with merged stats
966-
return self._parallel_capture(windows, max_depth=max_depth, refs=refs)
967-
968-
def _parallel_capture(
969-
self,
970-
windows: list[dict[str, Any]],
971-
*,
972-
max_depth: int = 999,
973-
refs: dict[str, Any],
974-
) -> tuple[list[dict], dict, dict[str, Any]]:
975-
"""Walk multiple window trees in parallel threads."""
976-
# Shared counter for globally unique IDs
953+
# AT-SPI2 D-Bus calls are not thread-safe — always walk sequentially.
954+
# (Previously used ThreadPoolExecutor for multi-window, but that caused
955+
# segfaults because GObject/D-Bus proxies aren't safe across threads.)
977956
id_gen = itertools.count()
978-
num_workers = min(len(windows), 8)
979-
980-
per_window_results: list[tuple[dict | None, dict]] = [(None, {}) for _ in windows]
981-
982-
def walk_one(idx: int):
983-
win = windows[idx]
984-
local_stats: dict = {"nodes": 0, "max_depth": 0, "roles": {}}
957+
stats: dict = {"nodes": 0, "max_depth": 0, "roles": {}}
958+
tree: list[dict] = []
959+
for win in windows:
985960
node = _build_cup_node(
986961
win["handle"],
987962
id_gen,
988-
local_stats,
963+
stats,
989964
0,
990965
max_depth,
991966
self._screen_w,
992967
self._screen_h,
993968
refs,
994969
)
995-
per_window_results[idx] = (node, local_stats)
996-
997-
with ThreadPoolExecutor(max_workers=num_workers) as pool:
998-
list(pool.map(walk_one, range(len(windows))))
999-
1000-
# Merge results
1001-
tree: list[dict] = []
1002-
merged_stats: dict = {"nodes": 0, "max_depth": 0, "roles": {}}
1003-
for node, st in per_window_results:
1004970
if node is not None:
1005971
tree.append(node)
1006-
merged_stats["nodes"] += st.get("nodes", 0)
1007-
merged_stats["max_depth"] = max(merged_stats["max_depth"], st.get("max_depth", 0))
1008-
for role, count in st.get("roles", {}).items():
1009-
merged_stats["roles"][role] = merged_stats["roles"].get(role, 0) + count
972+
return tree, stats, refs
1010973

1011-
return tree, merged_stats, refs
1012974

1013975

1014976
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)