From a6b68a0bf8aaf8f2fd275e2327af945b2385d3a8 Mon Sep 17 00:00:00 2001 From: minhanhpkpro Date: Fri, 25 Sep 2026 14:40:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(app):=20ghi=20log=20khi=20app=20v=C4=83ng/?= =?UTF-8?q?treo=20v=C3=A0=20b=C3=A1o=20"=C4=90ang=20x=E1=BB=AD=20l=C3=BD?= =?UTF-8?q?=E2=80=A6"=20khi=20giao=20di=E1=BB=87n=20=C4=91=E1=BB=A9ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app.py | 12 ++ i18n/login_dialog.py | 6 + presentation/shell/crash_guard.py | 171 ++++++++++++++++++++++++++ tests/integration/test_crash_guard.py | 96 +++++++++++++++ 4 files changed, 285 insertions(+) create mode 100644 presentation/shell/crash_guard.py create mode 100644 tests/integration/test_crash_guard.py diff --git a/app.py b/app.py index 1db3034..be7d372 100644 --- a/app.py +++ b/app.py @@ -72,6 +72,12 @@ def run(argv: List[str] | None = None) -> int: app = QApplication.instance() or QApplication(argv) app.setApplicationName(APP_NAME) 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 # 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. @@ -132,4 +138,10 @@ def run(argv: List[str] | None = None) -> int: pass 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() diff --git a/i18n/login_dialog.py b/i18n/login_dialog.py index c5db3ae..f154b32 100644 --- a/i18n/login_dialog.py +++ b/i18n/login_dialog.py @@ -155,6 +155,12 @@ STRINGS: Dict[str, Dict[str, str]] = { "app.lang.switching": { "en": "Switching language…", "ja": "言語を切り替えています…", "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.schedule": {"en": "Schedule Task", "ja": "タスクスケジュール", "vi": "Schedule Task"}, "app.tab.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"}, diff --git a/presentation/shell/crash_guard.py b/presentation/shell/crash_guard.py new file mode 100644 index 0000000..a9fa196 --- /dev/null +++ b/presentation/shell/crash_guard.py @@ -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 diff --git a/tests/integration/test_crash_guard.py b/tests/integration/test_crash_guard.py new file mode 100644 index 0000000..f7d7825 --- /dev/null +++ b/tests/integration/test_crash_guard.py @@ -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]