b8a3ce35 by shady

:bug: 历史多触发闪退三面修:消除 model 重复路径 + 清 result_bytes + 子线程异常兜底

用户症状: 历史记录条数多时容易闪退。沿数据流追到三个独立问题。

A) core/history.py HistoryListModel._get_or_build eager 构造 QIcon
   (PIL + QPixmap scaled + QIcon, ~43KB/项), 但 QML delegate 走
   history.thumbnailPath() 自管缩略图, 从不引用 model.DecorationRole.
   旧 QListView IconMode 路径已 .txt 化弃用. 这条 eager 路径 N=500+
   时常驻 ~22MB GUI 资源 + 主线程 PIL/scaled 卡顿放大. 改为
   data(DecorationRole) lazy build + 不缓存, _get_or_build 只构造
   text/tooltip.

B) task_queue.py _on_task_completed emit 后立即 task.result_bytes = None.
   bytes 已落 history + result_path 已写回, 内存里副本浪费.
   长跑 N 条已完成任务时常驻 N * 10-15MB (2K 图), 触发 OOM/SIGKILL.
   sidebar 回显走 result_path, 不受影响.

C) 诊断设施补强 (下次崩能拿到证据):
   - core/runtime.py: 加 threading.excepthook (Python 3.8+).
     sys.excepthook 只主线程生效, 子线程未捕获异常默认无声死亡.
   - main_qml.py: aboutToQuit 加 flush_logs() 退出最后一秒 INFO 不丢.
   - main_qml.py: QQmlApplicationEngine.warnings + objectCreationFailed
     信号导到 logger. 之前 QML 错误仅打 stderr, frozen windowed 无痕.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4ce046be
......@@ -122,7 +122,14 @@ class HistoryListModel(QAbstractListModel):
if role == Qt.ToolTipRole:
return cached.get('tooltip', '')
if role == Qt.DecorationRole:
return cached.get('icon')
# QML delegate 走 history.thumbnailPath() 自管缩略图, 不会走到这条路径;
# 仅旧的 QListView IconMode 会用到 (image_generator.py 已 .txt 化).
# 之前在 _get_or_build 里 eager 构造 QIcon 是死路径, N=500+ 时常驻
# ~22MB GUI 资源 + 主线程 PIL/scaled 卡顿放大. 改为按需构造 + 不缓存.
item = cached.get('item')
if item is None:
return self._build_placeholder_icon("已删除")
return self._load_thumbnail_icon(item)
return None
# ---- 增量操作 -----------------------------------------------------------
......@@ -168,7 +175,12 @@ class HistoryListModel(QAbstractListModel):
# ---- 内部 ---------------------------------------------------------------
def _get_or_build(self, timestamp: str) -> Optional[Dict[str, Any]]:
"""按需构建并 LRU 缓存渲染数据。"""
"""按需构建并 LRU 缓存渲染数据(text/tooltip + item, 不含 icon).
QIcon 不在此处构造: QML delegate 走 history.thumbnailPath() 自管,
不引用 model.DecorationRole; 旧 QListView IconMode 路径已弃用.
DecorationRole 真有人调时再 lazy build (见 data()).
"""
cached = self._cache.get(timestamp)
if cached is not None:
self._cache.move_to_end(timestamp)
......@@ -181,7 +193,6 @@ class HistoryListModel(QAbstractListModel):
if item is None:
placeholder = {
'item': None,
'icon': self._build_placeholder_icon("已删除"),
'display_text': f"{timestamp}\n(已删除)",
'tooltip': f"时间戳: {timestamp}\n记录已不存在",
}
......@@ -196,11 +207,8 @@ class HistoryListModel(QAbstractListModel):
f"宽高比: {item.aspect_ratio}\n"
f"尺寸: {item.image_size}"
)
icon = self._load_thumbnail_icon(item)
rendered = {
'item': item,
'icon': icon,
'display_text': display_text,
'tooltip': tooltip,
}
......
......@@ -201,7 +201,32 @@ def enable_crash_diagnostics() -> None:
sys.excepthook = _global_excepthook
# 3. Qt 消息拦截器
# 3. 子线程未捕获异常钩子 (Python 3.8+)
# sys.excepthook 只在主线程生效, QThread / threading.Thread 里逃出 try/except
# 的异常默认无声丢失 (Thread._bootstrap_inner 内部吞掉, 不调用 sys.excepthook).
# ImageGenerationWorker / audit UploadWorker / history 后台任务都需要这层兜底,
# 否则 worker 线程崩溃时 crash_log / app.log 都看不到 Python 栈.
def _thread_excepthook(args):
# SystemExit 走标准库语义: 正常退出, 不当 bug
if args.exc_type is SystemExit:
return
msg = "".join(traceback.format_exception(
args.exc_type, args.exc_value, args.exc_traceback
))
thread_name = args.thread.name if args.thread else "<unknown>"
logging.critical(f"线程 [{thread_name}] 未捕获异常:\n{msg}")
try:
with open(crash_log, "a", encoding="utf-8") as f:
f.write(
f"\n[THREAD UNCAUGHT] {datetime.now().isoformat()} "
f"thread={thread_name}\n{msg}\n"
)
except Exception:
pass
threading.excepthook = _thread_excepthook
# 4. Qt 消息拦截器
try:
from PySide6.QtCore import qInstallMessageHandler, QtMsgType
......
......@@ -49,6 +49,7 @@ from core.jewelry import JewelryLibraryManager # noqa: E402
from core.runtime import ( # noqa: E402
cleanup_clipboard_tempfiles,
enable_crash_diagnostics,
flush_logs,
init_logging,
log_system_info,
)
......@@ -302,6 +303,23 @@ def main():
# Phase 7: QML 装载
logger.info("[BOOT] Phase 7: 装载 QML…")
engine = QQmlApplicationEngine()
# 把 QML 引擎的 warnings / 对象创建失败 导到 logger.
# qInstallMessageHandler 只拦 C++ 侧 qDebug/qWarning/qCritical;
# QML 编译错误 / binding loop / 资源 404 走的是 QQmlEngine.warnings 信号,
# 不导出的话只打到 stderr, frozen windowed 模式下 stderr 不存在 = 无痕.
def _on_qml_warnings(errors):
for e in errors:
logger.error(
f"[QML] {e.url().toString()}:{e.line()}:{e.column()} - {e.description()}"
)
def _on_qml_object_failed(url):
logger.error(f"[QML] 对象创建失败: {url.toString()}")
engine.warnings.connect(_on_qml_warnings)
engine.objectCreationFailed.connect(_on_qml_object_failed)
ctx = engine.rootContext()
ctx.setContextProperty("appState", app_state)
ctx.setContextProperty("auth", auth_bridge)
......@@ -320,13 +338,20 @@ def main():
# Phase 8: 退出 hook
def _flush_audit_on_quit():
if audit_logger_inst is None:
return
# audit_logger 先 flush, 把队列里没 upload 的写盘
if audit_logger_inst is not None:
try:
audit_logger_inst.shutdown(timeout=5.0)
logger.info("[QUIT] audit logger 已 flush")
except Exception:
logger.exception("[QUIT] audit shutdown 失败")
# 再强制把 logging buffer fsync 到磁盘. TieredFsyncHandler 只对 WARNING+
# 即时刷盘, INFO/DEBUG 走 1s 周期兜底; 退出最后一秒的 INFO 不刷会丢.
# macOS aboutToQuit 后 Python 关闭顺序里 atexit/handler 可能被 SIGKILL 截断.
try:
flush_logs()
except Exception:
pass
app.aboutToQuit.connect(_flush_audit_on_quit)
......
......@@ -266,6 +266,12 @@ class TaskQueueManager(QObject):
self.task_completed.emit(task_id, image_bytes, prompt, reference_images,
aspect_ratio, image_size, model)
# emit 是 DirectConnection (sender/receiver 都在主线程), 下游 bridges
# 已完成 history 落盘并写回 task.result_path。内存里的 bytes 副本到此可以释放,
# 否则每条已完成 task 常驻 ~10-15MB (2K 图), 长跑后水位高 → OOM/SIGKILL.
# sidebar 点击已完成任务回显走 task.result_path 文件路径, 不依赖 bytes.
task.result_bytes = None
# 清理旧任务历史,只保留最近的完成任务
self._cleanup_old_tasks()
......