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]