feat(app): ghi log khi app văng/treo và báo "Đang xử lý…" khi giao diện đứng

App văng ra với mã 0xC0000409 mà không để lại dấu vết: lỗi ở tầng native không in
traceback Python và console đóng theo app. crash_guard ghi vào
~/.cowork_local/logs/:

- crash.log: stack mọi luồng lúc tiến trình chết (faulthandler), cùng exception
  Python không ai bắt ở luồng chính lẫn luồng nền (chỉ ghi, app chạy tiếp).
- qt.log: cảnh báo/lỗi của Qt.
- hang.log: stack mọi luồng mỗi khi luồng giao diện đứng quá 5 giây.

Khi giao diện đứng, một luồng canh mở hộp thoại Windows "Đang xử lý…" (theo ngôn
ngữ đang chọn trong app) và tự đóng khi app phản hồi lại. Cài đặt bọc trong try,
không bao giờ chặn app khởi động.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
minhanhpkpro
2026-09-25 14:40:06 +09:00
co-authored by Claude Opus 5.5
parent 0b6b220bd9
commit a6b68a0bf8
4 changed files with 285 additions and 0 deletions
+12
View File
@@ -72,6 +72,12 @@ def run(argv: List[str] | None = None) -> int:
app = QApplication.instance() or QApplication(argv) app = QApplication.instance() or QApplication(argv)
app.setApplicationName(APP_NAME) app.setApplicationName(APP_NAME)
app.setWindowIcon(app_icon()) app.setWindowIcon(app_icon())
try:
from .config import CONFIG_DIR
from .presentation.shell import crash_guard
crash_guard.install(Path(CONFIG_DIR) / "logs")
except Exception: # noqa: BLE001 - crash logging must never block startup
crash_guard = None
# Composition Root: presentation/shell/bootstrap.py quyết định app chạy # Composition Root: presentation/shell/bootstrap.py quyết định app chạy
# bằng mảnh nào. Từ R02, đó là JsonConfigRepository + kho bí mật của hệ # bằng mảnh nào. Từ R02, đó là JsonConfigRepository + kho bí mật của hệ
# điều hành, không còn config.py::AppConfig. # điều hành, không còn config.py::AppConfig.
@@ -132,4 +138,10 @@ def run(argv: List[str] | None = None) -> int:
pass pass
win.show() win.show()
if crash_guard is not None:
try:
watchdog = crash_guard.HangWatchdog(DISPLAY_NAME, win)
app.aboutToQuit.connect(watchdog.stop)
except Exception: # noqa: BLE001
pass
return app.exec() return app.exec()
+6
View File
@@ -155,6 +155,12 @@ STRINGS: Dict[str, Dict[str, str]] = {
"app.lang.switching": { "app.lang.switching": {
"en": "Switching language…", "ja": "言語を切り替えています…", "en": "Switching language…", "ja": "言語を切り替えています…",
"vi": "Đang đổi ngôn ngữ…"}, "vi": "Đang đổi ngôn ngữ…"},
# Popup do luồng canh treo mở khi giao diện đứng quá vài giây
# (presentation/shell/crash_guard.py).
"app.hang.message": {
"en": "Processing, please wait…\n\nThis window closes by itself once the app responds again.",
"ja": "処理中です。しばらくお待ちください…\n\nアプリが応答を再開すると、このウィンドウは自動的に閉じます。",
"vi": "Đang xử lý, vui lòng đợi…\n\nCửa sổ này tự đóng khi app phản hồi lại."},
"app.tab.dashboard": {"en": "Dashboard", "ja": "ダッシュボード", "vi": "Dashboard"}, "app.tab.dashboard": {"en": "Dashboard", "ja": "ダッシュボード", "vi": "Dashboard"},
"app.tab.schedule": {"en": "Schedule Task", "ja": "タスクスケジュール", "vi": "Schedule Task"}, "app.tab.schedule": {"en": "Schedule Task", "ja": "タスクスケジュール", "vi": "Schedule Task"},
"app.tab.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"}, "app.tab.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
+171
View File
@@ -0,0 +1,171 @@
"""Ghi lại mọi lần app chết hoặc treo, và báo "Đang xử lý…" khi giao diện đứng.
Trước đây app văng ra (mã 0xC0000409) mà không để lại dấu vết gì: lỗi ở tầng
native không in traceback Python, còn cửa sổ console thì đóng ngay theo app. Mô-đun
này ghi mọi thứ vào ``~/.cowork_local/logs/``:
* ``crash.log``: stack của mọi luồng lúc tiến trình chết (``faulthandler``), kèm
exception Python không ai bắt, ở luồng chính lẫn luồng nền. Exception Python
chỉ được ghi lại, app vẫn chạy tiếp.
* ``qt.log``: cảnh báo, lỗi và lỗi nghiêm trọng của Qt (cũng vẫn in ra console).
* ``hang.log``: stack của mọi luồng mỗi khi luồng giao diện đứng quá
``HANG_SECONDS`` giây, để biết app đang kẹt ở dòng nào.
Luồng giao diện đang đứng thì không thể tự vẽ popup, nên popup "Đang xử lý…" do một
luồng canh riêng mở bằng hộp thoại gốc của Windows. Giao diện chạy lại thì hộp
thoại tự đóng.
"""
from __future__ import annotations
import datetime
import faulthandler
import sys
import threading
import time
import traceback
from pathlib import Path
from typing import Optional
from PySide6.QtCore import QTimer, QtMsgType, qInstallMessageHandler
HANG_SECONDS = 5.0 # luồng giao diện đứng quá ngần này giây thì coi là treo
_BEAT_MS = 500 # nhịp luồng giao diện báo "vẫn sống"
_crash_file = None # giữ file mở suốt đời app: faulthandler ghi vào lúc chết
_log_dir: Optional[Path] = None
def _stamp() -> str:
"""Mốc giờ hiện tại, dùng làm tiêu đề mỗi mục trong log."""
return datetime.datetime.now().isoformat(timespec="seconds")
def _append(name: str, text: str) -> None:
"""Ghi thêm vào một file log. Ghi log hỏng thì bỏ qua, không kéo app theo."""
if _log_dir is None:
return
try:
with open(_log_dir / name, "a", encoding="utf-8") as f:
f.write(text)
except OSError:
pass
def _log_exception(where: str, exc_type, exc, tb) -> None:
"""Ghi một exception không ai bắt vào crash.log và in ra console."""
body = "".join(traceback.format_exception(exc_type, exc, tb))
_append("crash.log", f"\n=== {_stamp()} exception chưa bắt ({where}) ===\n{body}")
if sys.__stderr__:
sys.__stderr__.write(body)
def _qt_message(mode, context, message) -> None:
"""Chuyển thông báo của Qt vào qt.log, vẫn in ra console như trước."""
level = {QtMsgType.QtWarningMsg: "WARNING", QtMsgType.QtCriticalMsg: "CRITICAL",
QtMsgType.QtFatalMsg: "FATAL"}.get(mode)
if sys.__stderr__:
sys.__stderr__.write(message + "\n")
if level is None:
return # debug/info: chỉ in, không ghi file
_append("qt.log", f"{_stamp()} {level}: {message}\n")
if mode == QtMsgType.QtFatalMsg and _crash_file is not None:
faulthandler.dump_traceback(_crash_file, all_threads=True)
def install(log_dir: Path) -> None:
"""Bật ghi log crash. Gọi một lần, sớm nhất có thể sau khi có QApplication."""
global _crash_file, _log_dir
log_dir.mkdir(parents=True, exist_ok=True)
_log_dir = log_dir
_crash_file = open(log_dir / "crash.log", "a", encoding="utf-8") # noqa: SIM115
_crash_file.write(f"\n=== {_stamp()} app khởi động ===\n")
_crash_file.flush()
faulthandler.enable(file=_crash_file, all_threads=True)
sys.excepthook = lambda t, e, tb: _log_exception("luồng chính", t, e, tb)
threading.excepthook = lambda a: _log_exception(
f"luồng {a.thread.name if a.thread else '?'}", a.exc_type, a.exc_value, a.exc_traceback)
qInstallMessageHandler(_qt_message)
class HangWatchdog:
"""Canh luồng giao diện: đứng quá ``HANG_SECONDS`` giây thì ghi stack vào
hang.log và mở popup "Đang xử lý…", chạy lại thì đóng popup.
Luồng giao diện đều đặn cập nhật nhịp qua một ``QTimer``; một luồng nền kiểm tra
nhịp đó. Luồng nền không đụng vào widget Qt nào.
"""
def __init__(self, title: str, parent=None) -> None:
self._title = title
self._last_beat = time.monotonic()
self._timer = QTimer(parent)
self._timer.timeout.connect(self._beat)
self._timer.start(_BEAT_MS)
self._stop = threading.Event()
self._popup: Optional[threading.Thread] = None
self._popup_title = title
threading.Thread(target=self._watch, name="hang-watchdog", daemon=True).start()
def stop(self) -> None:
"""Dừng canh (khi app thoát)."""
self._stop.set()
self._timer.stop()
def _beat(self) -> None:
self._last_beat = time.monotonic()
def _watch(self) -> None:
stalled = False
while not self._stop.wait(0.5):
lag = time.monotonic() - self._last_beat
if lag >= HANG_SECONDS and not stalled:
stalled = True
self._dump_stacks(lag)
self._show_popup()
elif lag < HANG_SECONDS and stalled:
stalled = False
self._close_popup()
def _dump_stacks(self, lag: float) -> None:
if _log_dir is None:
return
try:
with open(_log_dir / "hang.log", "a", encoding="utf-8") as f:
f.write(f"\n=== {_stamp()} giao diện đứng {lag:.1f}s ===\n")
f.flush()
faulthandler.dump_traceback(f, all_threads=True)
except OSError:
pass
def _show_popup(self) -> None:
if sys.platform != "win32":
return
import ctypes
from ...i18n import tr
# Đọc chữ NGAY LÚC hiện popup: luôn khớp ngôn ngữ đang chọn trong app,
# kể cả khi người dùng vừa đổi ngôn ngữ.
title, message = self._title, tr("app.hang.message")
# MB_ICONINFORMATION | MB_TOPMOST | MB_SETFOREGROUND. MessageBoxW có vòng
# thông điệp riêng trên luồng này, nên hiện được dù luồng giao diện đang đứng.
flags = 0x40 | 0x40000 | 0x10000
self._popup_title = title
self._popup = threading.Thread(
target=lambda: ctypes.windll.user32.MessageBoxW(None, message, title, flags),
name="hang-popup", daemon=True)
self._popup.start()
def _close_popup(self) -> None:
if self._popup is None or sys.platform != "win32":
return
import ctypes
user32 = ctypes.windll.user32
for _ in range(10): # hộp thoại có thể chưa kịp hiện ra
hwnd = user32.FindWindowW(None, self._popup_title)
if hwnd:
user32.PostMessageW(hwnd, 0x0010, 0, 0) # WM_CLOSE
break
time.sleep(0.05)
self._popup = None
+96
View File
@@ -0,0 +1,96 @@
"""crash_guard ghi lại exception chưa bắt và phát hiện giao diện bị treo."""
from __future__ import annotations
import os
import threading
import time
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
pytest.importorskip("PySide6")
from cowork_local.presentation.shell import crash_guard # noqa: E402
@pytest.fixture
def qt_app():
from PySide6.QtWidgets import QApplication
return QApplication.instance() or QApplication([])
@pytest.fixture
def guard(tmp_path, monkeypatch):
import faulthandler
import sys
from PySide6.QtCore import qInstallMessageHandler
monkeypatch.setattr(sys, "excepthook", sys.excepthook)
monkeypatch.setattr(threading, "excepthook", threading.excepthook)
crash_guard.install(tmp_path)
yield tmp_path
qInstallMessageHandler(None)
faulthandler.disable()
crash_guard._crash_file.close()
crash_guard._crash_file = None
crash_guard._log_dir = None
def test_uncaught_thread_exception_is_logged_not_fatal(guard):
def boom():
raise ValueError("lỗi luồng nền")
t = threading.Thread(target=boom, name="worker-x")
t.start()
t.join()
log = (guard / "crash.log").read_text(encoding="utf-8")
assert "ValueError: lỗi luồng nền" in log
assert "worker-x" in log
def test_watchdog_dumps_stacks_and_pops_up_while_gui_is_stalled(guard, qt_app, monkeypatch):
monkeypatch.setattr(crash_guard, "HANG_SECONDS", 0.6)
events = []
monkeypatch.setattr(crash_guard.HangWatchdog, "_show_popup", lambda self: events.append("show"))
monkeypatch.setattr(crash_guard.HangWatchdog, "_close_popup", lambda self: events.append("close"))
dog = crash_guard.HangWatchdog("t")
try:
qt_app.processEvents()
time.sleep(1.5) # luồng giao diện "đứng"
assert events == ["show"]
end = time.time() + 2
while "close" not in events and time.time() < end:
qt_app.processEvents() # giao diện chạy lại
time.sleep(0.05)
assert events == ["show", "close"]
finally:
dog.stop()
assert "giao diện đứng" in (guard / "hang.log").read_text(encoding="utf-8")
@pytest.mark.parametrize("lang, expected", [
("vi", "Đang xử lý"), ("en", "Processing"), ("ja", "処理中"),
])
def test_popup_text_follows_the_current_app_language(qt_app, monkeypatch, lang, expected):
import sys
import types
from cowork_local.i18n import get_language, set_language
shown = []
fake_user32 = types.SimpleNamespace(MessageBoxW=lambda h, msg, title, f: shown.append(msg))
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setitem(sys.modules, "ctypes", types.SimpleNamespace(
windll=types.SimpleNamespace(user32=fake_user32)))
before = get_language()
dog = crash_guard.HangWatchdog("t")
try:
set_language(lang)
dog._show_popup()
dog._popup.join(1)
finally:
dog.stop()
set_language(before)
assert shown and expected in shown[0]