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:
co-authored by
Claude Opus 5.5
parent
0b6b220bd9
commit
a6b68a0bf8
@@ -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
|
||||
Reference in New Issue
Block a user