|
9 | 9 | 2. Batch-reads core properties per node (role, name, description, states, |
10 | 10 | bounds, attributes, actions, value) in a single walk pass |
11 | 11 | 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) |
13 | 13 | """ |
14 | 14 |
|
15 | 15 | from __future__ import annotations |
|
19 | 19 | import itertools |
20 | 20 | import os |
21 | 21 | import subprocess |
22 | | -from concurrent.futures import ThreadPoolExecutor |
23 | 22 | from typing import Any |
24 | 23 |
|
25 | 24 | from cup._base import PlatformAdapter |
@@ -284,6 +283,15 @@ def _init_atspi(): |
284 | 283 | import gi |
285 | 284 |
|
286 | 285 | 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 | + |
287 | 295 | from gi.repository import Atspi |
288 | 296 |
|
289 | 297 | # Event listeners are not needed — we only read the tree. |
@@ -942,73 +950,27 @@ def capture_tree( |
942 | 950 | self.initialize() |
943 | 951 | refs: dict[str, Any] = {} |
944 | 952 |
|
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.) |
977 | 956 | 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: |
985 | 960 | node = _build_cup_node( |
986 | 961 | win["handle"], |
987 | 962 | id_gen, |
988 | | - local_stats, |
| 963 | + stats, |
989 | 964 | 0, |
990 | 965 | max_depth, |
991 | 966 | self._screen_w, |
992 | 967 | self._screen_h, |
993 | 968 | refs, |
994 | 969 | ) |
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: |
1004 | 970 | if node is not None: |
1005 | 971 | 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 |
1010 | 973 |
|
1011 | | - return tree, merged_stats, refs |
1012 | 974 |
|
1013 | 975 |
|
1014 | 976 | # --------------------------------------------------------------------------- |
|
0 commit comments