Skip to content

Commit 9c6b0ae

Browse files
Tests: Send Ladybird stderr to a file, not a pipe, in the a11y harnesses
Ladybird debug builds with ENABLE_ALL_THE_DEBUG_MACROS=ON (the All_Debug preset that runs the AT-SPI2 tests on CI) emit huge stderr volumes — PROMISE_DEBUG alone produces multiple lines per JS Promise step. The harness was running with stderr=subprocess.PIPE and not draining it until the timeout-failure path; once the kernel pipe buffer fills (~64 KB on Linux), every dbgln in WebContent blocks on write, which can wedge the engine before AT-SPI2 / NSAccessibility registration. Send stderr to a tempfile instead. The kernel never backpressures a regular file, the write path no longer wedges, and we still get the bytes to include in the failure RuntimeError. Read the file before stop() and unlink it on stop() so we don't leak debug logs in /tmp.
1 parent 912c676 commit 9c6b0ae

2 files changed

Lines changed: 58 additions & 39 deletions

File tree

Tests/LibWeb/AccessibilityBridge/harness/ladybird.py

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import os
66
import pathlib
77
import subprocess
8+
import tempfile
89
import time
910

1011
from 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()
@@ -81,11 +83,17 @@ def start(self) -> None:
8183
# Silence Qt's a11y logging noise — so failures are readable.
8284
env.setdefault("QT_LOGGING_RULES", "qt.accessibility.atspi=false")
8385

86+
# Send stderr to a regular file rather than subprocess.PIPE: Ladybird debug builds (ENABLE_ALL_THE_DEBUG_MACROS)
87+
# produce huge stderr volumes (PROMISE_DEBUG, etc.). A pipe's kernel buffer is ~64 KB; once it fills, every
88+
# dbgln in WebContent blocks on write — which can wedge the engine before AT-SPI2 registration completes. A
89+
# regular file never backpressures the writer, and we still get the bytes for diagnostics on failure.
90+
self._stderr_file = tempfile.NamedTemporaryFile(prefix="ladybird-a11y-stderr-", suffix=".log", delete=False)
91+
8492
self._process = subprocess.Popen(
8593
[str(self._binary), self._url],
8694
env=env,
8795
stdout=subprocess.DEVNULL,
88-
stderr=subprocess.PIPE,
96+
stderr=self._stderr_file,
8997
)
9098

9199
# Start a fresh Atspi connection for this context. Atspi.init() is idempotent — subsequent tests share the
@@ -115,24 +123,14 @@ def _app_appeared() -> bool:
115123

116124
startup_timeout = float(os.environ.get("LADYBIRD_AT_SPI2_STARTUP_TIMEOUT", "60"))
117125
if not wait_for(_app_appeared, timeout=startup_timeout, description="Ladybird AT-SPI2 app to appear"):
118-
# Capture stderr *before* stop() — because stop() nulls out self._process. Use communicate() with a hard
119-
# timeout — so a wedged Ladybird that ignores SIGTERM can't hang the harness here; kill() + communicate()
120-
# are guaranteed to return.
126+
# Read the stderr file *before* stop() — stop() unlinks it.
121127
stderr = ""
122-
if self._process is not None:
128+
if self._stderr_file is not None:
123129
try:
124-
self._process.terminate()
125-
_, stderr_bytes = self._process.communicate(timeout=5)
126-
except subprocess.TimeoutExpired:
127-
self._process.kill()
128-
try:
129-
_, stderr_bytes = self._process.communicate(timeout=5)
130-
except Exception:
131-
stderr_bytes = b""
130+
with open(self._stderr_file.name, "rb") as f:
131+
stderr = f.read().decode(errors="replace")
132132
except Exception:
133-
stderr_bytes = b""
134-
if stderr_bytes:
135-
stderr = stderr_bytes.decode(errors="replace")
133+
pass
136134
self.stop()
137135
raise RuntimeError(
138136
f"Ladybird did not register on AT-SPI2 within {startup_timeout:g}s.\n"
@@ -141,6 +139,7 @@ def _app_appeared() -> bool:
141139

142140
def stop(self) -> None:
143141
if self._process is None:
142+
self._cleanup_stderr_file()
144143
return
145144
try:
146145
self._process.terminate()
@@ -152,6 +151,20 @@ def stop(self) -> None:
152151
finally:
153152
self._process = None
154153
self._app = None
154+
self._cleanup_stderr_file()
155+
156+
def _cleanup_stderr_file(self) -> None:
157+
if self._stderr_file is None:
158+
return
159+
try:
160+
self._stderr_file.close()
161+
except Exception:
162+
pass
163+
try:
164+
os.unlink(self._stderr_file.name)
165+
except Exception:
166+
pass
167+
self._stderr_file = None
155168

156169
def _find_app_on_desktop(self) -> Optional[Atspi.Accessible]:
157170
expected_pid = self._process.pid if self._process is not None else None

Tests/LibWeb/AccessibilityBridgeMac/harness/ladybird.py

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import os
66
import pathlib
77
import subprocess
8+
import tempfile
89
import time
910

1011
from contextlib import contextmanager
@@ -77,6 +78,7 @@ def __init__(self, url: str, binary: Optional[pathlib.Path] = None):
7778
self._process: Optional[subprocess.Popen] = None
7879
self._app = None
7980
self._web = None
81+
self._stderr_file: Optional[tempfile.NamedTemporaryFile] = None
8082

8183
def __enter__(self) -> "LadybirdContext":
8284
self.start()
@@ -103,11 +105,17 @@ def start(self) -> None:
103105

104106
env = os.environ.copy()
105107

108+
# Send stderr to a regular file rather than subprocess.PIPE: Ladybird debug builds (ENABLE_ALL_THE_DEBUG_MACROS)
109+
# produce huge stderr volumes (PROMISE_DEBUG, etc.). A pipe's kernel buffer is ~64 KB; once it fills, every
110+
# dbgln in WebContent blocks on write — which can wedge the engine before AX registration completes. A regular
111+
# file never backpressures the writer, and we still get the bytes for diagnostics on failure.
112+
self._stderr_file = tempfile.NamedTemporaryFile(prefix="ladybird-ax-stderr-", suffix=".log", delete=False)
113+
106114
self._process = subprocess.Popen(
107115
[str(self._binary), self._url],
108116
env=env,
109117
stdout=subprocess.DEVNULL,
110-
stderr=subprocess.PIPE,
118+
stderr=self._stderr_file,
111119
)
112120

113121
self._app = AXUIElementCreateApplication(self._process.pid)
@@ -127,24 +135,14 @@ def _web_appeared() -> bool:
127135

128136
startup_timeout = float(os.environ.get("LADYBIRD_AX_STARTUP_TIMEOUT", "60"))
129137
if not wait_for(_web_appeared, timeout=startup_timeout, description="Ladybird AXWebArea to populate"):
130-
# Capture stderr *before* stop() — because stop() nulls out self._process. Use communicate() with a hard
131-
# timeout — so a wedged Ladybird that ignores SIGTERM can't hang the harness here; kill() + communicate()
132-
# are guaranteed to return.
138+
# Read the stderr file *before* stop() — stop() unlinks it.
133139
stderr = ""
134-
if self._process is not None:
140+
if self._stderr_file is not None:
135141
try:
136-
self._process.terminate()
137-
_, stderr_bytes = self._process.communicate(timeout=5)
138-
except subprocess.TimeoutExpired:
139-
self._process.kill()
140-
try:
141-
_, stderr_bytes = self._process.communicate(timeout=5)
142-
except Exception:
143-
stderr_bytes = b""
142+
with open(self._stderr_file.name, "rb") as f:
143+
stderr = f.read().decode(errors="replace")
144144
except Exception:
145-
stderr_bytes = b""
146-
if stderr_bytes:
147-
stderr = stderr_bytes.decode(errors="replace")
145+
pass
148146
self.stop()
149147
raise RuntimeError(
150148
f"Ladybird did not register an AXWebArea on NSAccessibility within {startup_timeout:g}s.\n"
@@ -153,6 +151,7 @@ def _web_appeared() -> bool:
153151

154152
def stop(self) -> None:
155153
if self._process is None:
154+
self._cleanup_stderr_file()
156155
return
157156
try:
158157
self._process.terminate()
@@ -162,16 +161,23 @@ def stop(self) -> None:
162161
self._process.kill()
163162
self._process.wait(timeout=5)
164163
finally:
165-
# Close the stderr pipe explicitly. Otherwise Python's GC complains with ResourceWarning — which makes
166-
# unittest output noisy.
167-
if self._process and self._process.stderr is not None:
168-
try:
169-
self._process.stderr.close()
170-
except Exception:
171-
pass
172164
self._process = None
173165
self._app = None
174166
self._web = None
167+
self._cleanup_stderr_file()
168+
169+
def _cleanup_stderr_file(self) -> None:
170+
if self._stderr_file is None:
171+
return
172+
try:
173+
self._stderr_file.close()
174+
except Exception:
175+
pass
176+
try:
177+
os.unlink(self._stderr_file.name)
178+
except Exception:
179+
pass
180+
self._stderr_file = None
175181

176182
def _find_web_area(self):
177183
"""Walk from the application's focused/main window down to the first AXWebArea descendant."""

0 commit comments

Comments
 (0)