Skip to content

Commit 8bb643b

Browse files
committed
fix(Ui/output): make log widget appends thread-safe to prevent GUI segfaults
Signed-off-by: Samuel Amen Ague <ague.samuel27@gmail.com>
1 parent c35a5a9 commit 8bb643b

2 files changed

Lines changed: 240 additions & 34 deletions

File tree

pycompiler_ark/Ui/output.py

Lines changed: 121 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
import sys
1919
import re
20-
from typing import Optional, Any
20+
from typing import Optional, Any, Callable
2121

2222
try:
2323
from rich.console import Console # type: ignore
@@ -58,6 +58,16 @@
5858
re.UNICODE,
5959
)
6060

61+
_LOG_COLORS = {
62+
"info": "#00aaff",
63+
"warning": "#ffaa00",
64+
"error": "#ff4444",
65+
"success": "#00cc66",
66+
}
67+
68+
# Global widget cache
69+
_log_widget = None
70+
6171

6272
def strip_emojis(text: str) -> str:
6373
"""Remove emojis from a string for cleaner terminal output."""
@@ -90,10 +100,6 @@ def plain(message: str, err: bool = False) -> None:
90100
_emit(message, err=err)
91101

92102

93-
# Global widget cache
94-
_log_widget = None
95-
96-
97103
def get_log_widget():
98104
global _log_widget
99105
if _log_widget is not None:
@@ -113,13 +119,108 @@ def get_log_widget():
113119
return None
114120

115121

122+
def _is_on_gui_thread() -> bool:
123+
"""Return True when there is no Qt app, or we are on the app's thread."""
124+
try:
125+
from PySide6.QtCore import QThread
126+
from PySide6.QtWidgets import QApplication
127+
128+
app = QApplication.instance()
129+
if app is None:
130+
return True
131+
return QThread.currentThread() == app.thread()
132+
except Exception:
133+
return True
134+
135+
136+
def _append_html_to_widget(widget: Any, html: str, plain_text: str) -> None:
137+
"""Mutate the log widget. Must only run on the Qt GUI thread."""
138+
try:
139+
from PySide6.QtGui import QTextCursor
140+
141+
widget.moveCursor(QTextCursor.MoveOperation.End)
142+
widget.insertHtml(html)
143+
widget.ensureCursorVisible()
144+
except Exception:
145+
try:
146+
widget.append(plain_text)
147+
except Exception:
148+
pass
149+
150+
151+
def _post_to_gui_thread(fn: Callable[[], None]) -> bool:
152+
"""Queue *fn* on the Qt GUI thread. Returns False if posting is impossible."""
153+
try:
154+
from PySide6.QtCore import QTimer
155+
from PySide6.QtWidgets import QApplication
156+
157+
app = QApplication.instance()
158+
if app is None:
159+
return False
160+
# Context=app → functor runs on the application (GUI) thread.
161+
QTimer.singleShot(0, app, fn)
162+
return True
163+
except Exception:
164+
return False
165+
166+
167+
def _safe_append_to_widget(widget: Any, html: str, plain_text: str) -> None:
168+
"""Append to a Qt log widget from any thread without segfaulting."""
169+
if widget is None:
170+
return
171+
172+
# Bridge / non-QTextEdit objects expose a thread-safe append via signals.
173+
try:
174+
from PySide6.QtWidgets import QTextEdit
175+
176+
if not isinstance(widget, QTextEdit):
177+
if callable(getattr(widget, "append", None)):
178+
widget.append(plain_text)
179+
return
180+
except Exception:
181+
# Qt unavailable: best-effort append
182+
try:
183+
if callable(getattr(widget, "append", None)):
184+
widget.append(plain_text)
185+
except Exception:
186+
pass
187+
return
188+
189+
if _is_on_gui_thread():
190+
_append_html_to_widget(widget, html, plain_text)
191+
return
192+
193+
# Off GUI thread: never touch QTextEdit directly (segfault).
194+
posted = _post_to_gui_thread(
195+
lambda w=widget, h=html, p=plain_text: _append_html_to_widget(w, h, p)
196+
)
197+
if not posted:
198+
# No event loop / posting failed — skip widget; console _emit still runs.
199+
return
200+
201+
202+
def _try_bridge_log(gui: object | None, level: str, message: str) -> bool:
203+
"""Route through SafeGuiBridge-style API when available (already thread-safe)."""
204+
if gui is None:
205+
return False
206+
log_message_level = getattr(gui, "log_message_level", None)
207+
if not callable(log_message_level):
208+
return False
209+
try:
210+
# Message is already translated; pass twice for (fr, en).
211+
log_message_level(level, message, message)
212+
return True
213+
except Exception:
214+
return False
215+
216+
116217
def log(
117218
level: str,
118219
message: str | tuple | list | Any,
119220
err: bool | None = None,
120221
gui: object | None = None,
121222
) -> None:
122-
"""Log principal avec append automatique"""
223+
"""Log principal avec append automatique (thread-safe pour le widget GUI)."""
123224

124225
lvl = level.upper().strip()
125226

@@ -148,43 +249,29 @@ def log(
148249
message = str(message)
149250

150251
out_err = err if err is not None else lvl in ("ERROR", "WARN", "WARNING")
252+
plain_line = f"{prefix} {message}"
151253

152-
# === Append AUTOMATIQUE dans le widget 'log' ===
153-
widget = get_log_widget()
154-
if widget is None and gui is not None and hasattr(gui, "log"):
155-
widget = gui.log
254+
# Prefer SafeGuiBridge (signals) when the caller passes one — avoids
255+
# touching QTextEdit from a worker thread and prevents double-writes.
256+
bridged = _try_bridge_log(gui, style, str(message))
156257

157-
if widget is not None:
158-
try:
159-
from PySide6.QtGui import QTextCursor
258+
if not bridged:
259+
widget = get_log_widget()
260+
if widget is None and gui is not None and hasattr(gui, "log"):
261+
widget = gui.log
160262

161-
colors = {
162-
"info": "#00aaff",
163-
"warning": "#ffaa00",
164-
"error": "#ff4444",
165-
"success": "#00cc66",
166-
}
167-
color = colors.get(style, "#ffffff")
168-
169-
html = f'<span style="color:{color};">{prefix} {message}</span><br>'
170-
171-
widget.moveCursor(QTextCursor.MoveOperation.End)
172-
widget.insertHtml(html)
173-
widget.ensureCursorVisible()
174-
175-
except Exception:
176-
try:
177-
widget.append(f"{prefix} {message}")
178-
except Exception:
179-
pass
263+
if widget is not None:
264+
color = _LOG_COLORS.get(style, "#ffffff")
265+
html = f'<span style="color:{color};">{plain_line}</span><br>'
266+
_safe_append_to_widget(widget, html, plain_line)
180267

181268
# Appel sûr à _emit (sans exc_info)
182269
try:
183-
_emit(f"{prefix} {message}", err=out_err, style=style)
270+
_emit(plain_line, err=out_err, style=style)
184271
except TypeError:
185272
# Si _emit a une autre signature
186273
try:
187-
_emit(f"{prefix} {message}", err=out_err)
274+
_emit(plain_line, err=out_err)
188275
except Exception:
189276
pass # dernier recours
190277

tests/test_output_thread_safety.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright 2026 Samuel Amen Ague
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""Thread-safety of Ui.output log widget appends (GUI segfault guard)."""
17+
18+
from __future__ import annotations
19+
20+
import os
21+
import threading
22+
import time
23+
24+
import pytest
25+
26+
# Must be set before QApplication is created.
27+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
28+
29+
pytest.importorskip("PySide6")
30+
31+
from PySide6.QtWidgets import QApplication, QMainWindow, QTextEdit
32+
33+
34+
@pytest.fixture(scope="module")
35+
def qapp():
36+
app = QApplication.instance()
37+
if app is None:
38+
app = QApplication(["test_output_thread_safety"])
39+
return app
40+
41+
42+
def test_output_info_from_worker_thread_does_not_segfault(qapp, monkeypatch):
43+
from pycompiler_ark.Ui import output
44+
45+
window = QMainWindow()
46+
log_widget = QTextEdit()
47+
window.setCentralWidget(log_widget)
48+
window.log = log_widget
49+
window.show()
50+
qapp.processEvents()
51+
52+
monkeypatch.setattr(output, "_log_widget", log_widget)
53+
54+
errors: list[BaseException] = []
55+
56+
def _worker():
57+
try:
58+
for i in range(20):
59+
output.info(f"worker-line-{i}")
60+
except BaseException as exc: # noqa: BLE001
61+
errors.append(exc)
62+
63+
thread = threading.Thread(target=_worker)
64+
thread.start()
65+
66+
deadline = time.time() + 3.0
67+
while thread.is_alive() and time.time() < deadline:
68+
qapp.processEvents()
69+
time.sleep(0.01)
70+
71+
thread.join(timeout=2.0)
72+
for _ in range(50):
73+
qapp.processEvents()
74+
time.sleep(0.01)
75+
76+
assert not thread.is_alive()
77+
assert errors == []
78+
text = log_widget.toPlainText()
79+
assert "worker-line-0" in text
80+
assert "worker-line-19" in text
81+
82+
window.close()
83+
84+
85+
def test_output_info_on_gui_thread_still_works(qapp, monkeypatch):
86+
from pycompiler_ark.Ui import output
87+
88+
window = QMainWindow()
89+
log_widget = QTextEdit()
90+
window.setCentralWidget(log_widget)
91+
window.log = log_widget
92+
93+
monkeypatch.setattr(output, "_log_widget", log_widget)
94+
95+
output.info("main-thread-line")
96+
qapp.processEvents()
97+
98+
assert "main-thread-line" in log_widget.toPlainText()
99+
window.close()
100+
101+
102+
def test_bridge_log_message_level_preferred(qapp, monkeypatch):
103+
from pycompiler_ark.Ui import output
104+
105+
calls: list[tuple] = []
106+
107+
class Bridge:
108+
def log_message_level(self, level, fr, en):
109+
calls.append((level, fr, en))
110+
111+
# Even with a cached QTextEdit, bridge must win to avoid double-write.
112+
fake_widget = QTextEdit()
113+
monkeypatch.setattr(output, "_log_widget", fake_widget)
114+
115+
output.info("bridged-msg", gui=Bridge())
116+
qapp.processEvents()
117+
118+
assert calls == [("info", "bridged-msg", "bridged-msg")]
119+
assert "bridged-msg" not in fake_widget.toPlainText()

0 commit comments

Comments
 (0)