55import os
66import pathlib
77import subprocess
8+ import tempfile
89import time
910
1011from contextlib import contextmanager
@@ -60,6 +61,7 @@ def __init__(self, url: str, binary: Optional[pathlib.Path] = None):
6061 self ._binary = DEFAULT_BINARY
6162 self ._process : Optional [subprocess .Popen ] = None
6263 self ._app : Optional [Atspi .Accessible ] = None
64+ self ._stderr_file : Optional [tempfile .NamedTemporaryFile ] = None
6365
6466 def __enter__ (self ) -> "LadybirdContext" :
6567 self .start ()
@@ -80,12 +82,27 @@ def start(self) -> None:
8082 env .setdefault ("QT_QPA_PLATFORM" , "xcb" )
8183 # Silence Qt's a11y logging noise — so failures are readable.
8284 env .setdefault ("QT_LOGGING_RULES" , "qt.accessibility.atspi=false" )
83-
85+ # By default, Qt only activates its AT-SPI2 bridge when something signals that AT is "enabled" — usually a
86+ # desktop-environment toolkit setting (e.g. GNOME's org.gnome.desktop.interface.toolkit-accessibility, which
87+ # gets reflected onto the org.a11y.Status D-Bus property). On a headless CI runner there's no DE backend
88+ # setting that flag, so even with at-spi-bus-launcher and at-spi2-registryd both alive, Qt declines to attach.
89+ # This env var tells Qt to skip the IsEnabled check and bring the bridge up unconditionally.
90+ env .setdefault ("QT_LINUX_ACCESSIBILITY_ALWAYS_ON" , "1" )
91+
92+ # Send stderr to a regular file rather than subprocess.PIPE: Ladybird debug builds (ENABLE_ALL_THE_DEBUG_MACROS)
93+ # produce huge stderr volumes (PROMISE_DEBUG, etc.). A pipe's kernel buffer is ~64 KB; once it fills, every
94+ # dbgln in WebContent blocks on write — which can wedge the engine before AT-SPI2 registration completes. A
95+ # regular file never backpressures the writer, and we still get the bytes for diagnostics on failure.
96+ self ._stderr_file = tempfile .NamedTemporaryFile (prefix = "ladybird-a11y-stderr-" , suffix = ".log" , delete = False )
97+
98+ # --force-cpu-painting: skip the GPU/Vulkan path. Headless CI runners have no Vulkan ICD, and per
99+ # Services/WebContent/main.cpp the CPU backend is also what other test harnesses use ("the GPU backend is not
100+ # deterministic"). The Vulkan probe failure itself is non-fatal — we just don't want it cluttering diagnostics.
84101 self ._process = subprocess .Popen (
85- [str (self ._binary ), self ._url ],
102+ [str (self ._binary ), "--force-cpu-painting" , self ._url ],
86103 env = env ,
87104 stdout = subprocess .DEVNULL ,
88- stderr = subprocess . PIPE ,
105+ stderr = self . _stderr_file ,
89106 )
90107
91108 # Start a fresh Atspi connection for this context. Atspi.init() is idempotent — subsequent tests share the
@@ -113,21 +130,54 @@ def _app_appeared() -> bool:
113130 self ._app = app
114131 return True
115132
116- if not wait_for (_app_appeared , timeout = 15.0 , description = "Ladybird AT-SPI2 app to appear" ):
117- self .stop ()
133+ startup_timeout = float (os .environ .get ("LADYBIRD_AT_SPI2_STARTUP_TIMEOUT" , "60" ))
134+ if not wait_for (_app_appeared , timeout = startup_timeout , description = "Ladybird AT-SPI2 app to appear" ):
135+ # Snapshot diagnostics *before* stop() tears the process and stderr file down. The point of this branch is
136+ # to make the next CI run's failure say *why* the app didn't appear: did Ladybird die? Is something else
137+ # registered on the desktop? Did Ladybird register under a different name?
138+ expected_pid = self ._process .pid if self ._process is not None else None
139+ process_alive = self ._process is not None and self ._process .poll () is None
140+ process_returncode = self ._process .returncode if self ._process is not None else None
141+ desktop_children : list [str ] = []
142+ try :
143+ desktop = Atspi .get_desktop (0 )
144+ for i in range (desktop .get_child_count ()):
145+ child = desktop .get_child_at_index (i )
146+ if child is None :
147+ desktop_children .append (f"[{ i } ] <None>" )
148+ continue
149+ try :
150+ child_name = child .get_name () or "<unnamed>"
151+ except Exception as e :
152+ child_name = f"<get_name failed: { e } >"
153+ try :
154+ child_pid = child .get_process_id ()
155+ except Exception :
156+ child_pid = None
157+ desktop_children .append (f"[{ i } ] name={ child_name !r} pid={ child_pid } " )
158+ except Exception as e :
159+ desktop_children .append (f"<desktop enumeration failed: { e } >" )
160+
118161 stderr = ""
119- if self ._process and self . _process . stderr :
162+ if self ._stderr_file is not None :
120163 try :
121- stderr = self ._process .stderr .read ().decode (errors = "replace" )
164+ with open (self ._stderr_file .name , "rb" ) as f :
165+ stderr = f .read ().decode (errors = "replace" )
122166 except Exception :
123167 pass
168+ self .stop ()
124169 raise RuntimeError (
125- "Ladybird did not register on AT-SPI2 within 15s.\n "
126- f"URL: { self ._url } \n Binary: { self ._binary } \n Stderr: { stderr [- 2000 :]} "
170+ f"Ladybird did not register on AT-SPI2 within { startup_timeout :g} s.\n "
171+ f"URL: { self ._url } \n Binary: { self ._binary } \n "
172+ f"Expected PID: { expected_pid } , alive: { process_alive } , returncode: { process_returncode } \n "
173+ f"AT-SPI2 desktop children ({ len (desktop_children )} ):\n "
174+ + "\n " .join (desktop_children )
175+ + f"\n Stderr: { stderr [- 2000 :]} "
127176 )
128177
129178 def stop (self ) -> None :
130179 if self ._process is None :
180+ self ._cleanup_stderr_file ()
131181 return
132182 try :
133183 self ._process .terminate ()
@@ -139,6 +189,20 @@ def stop(self) -> None:
139189 finally :
140190 self ._process = None
141191 self ._app = None
192+ self ._cleanup_stderr_file ()
193+
194+ def _cleanup_stderr_file (self ) -> None :
195+ if self ._stderr_file is None :
196+ return
197+ try :
198+ self ._stderr_file .close ()
199+ except Exception :
200+ pass
201+ try :
202+ os .unlink (self ._stderr_file .name )
203+ except Exception :
204+ pass
205+ self ._stderr_file = None
142206
143207 def _find_app_on_desktop (self ) -> Optional [Atspi .Accessible ]:
144208 expected_pid = self ._process .pid if self ._process is not None else None
0 commit comments