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): ...@@ -122,7 +122,14 @@ class HistoryListModel(QAbstractListModel):
122 if role == Qt.ToolTipRole: 122 if role == Qt.ToolTipRole:
123 return cached.get('tooltip', '') 123 return cached.get('tooltip', '')
124 if role == Qt.DecorationRole: 124 if role == Qt.DecorationRole:
125 return cached.get('icon') 125 # QML delegate 走 history.thumbnailPath() 自管缩略图, 不会走到这条路径;
126 # 仅旧的 QListView IconMode 会用到 (image_generator.py 已 .txt 化).
127 # 之前在 _get_or_build 里 eager 构造 QIcon 是死路径, N=500+ 时常驻
128 # ~22MB GUI 资源 + 主线程 PIL/scaled 卡顿放大. 改为按需构造 + 不缓存.
129 item = cached.get('item')
130 if item is None:
131 return self._build_placeholder_icon("已删除")
132 return self._load_thumbnail_icon(item)
126 return None 133 return None
127 134
128 # ---- 增量操作 ----------------------------------------------------------- 135 # ---- 增量操作 -----------------------------------------------------------
...@@ -168,7 +175,12 @@ class HistoryListModel(QAbstractListModel): ...@@ -168,7 +175,12 @@ class HistoryListModel(QAbstractListModel):
168 # ---- 内部 --------------------------------------------------------------- 175 # ---- 内部 ---------------------------------------------------------------
169 176
170 def _get_or_build(self, timestamp: str) -> Optional[Dict[str, Any]]: 177 def _get_or_build(self, timestamp: str) -> Optional[Dict[str, Any]]:
171 """按需构建并 LRU 缓存渲染数据。""" 178 """按需构建并 LRU 缓存渲染数据(text/tooltip + item, 不含 icon).
179
180 QIcon 不在此处构造: QML delegate 走 history.thumbnailPath() 自管,
181 不引用 model.DecorationRole; 旧 QListView IconMode 路径已弃用.
182 DecorationRole 真有人调时再 lazy build (见 data()).
183 """
172 cached = self._cache.get(timestamp) 184 cached = self._cache.get(timestamp)
173 if cached is not None: 185 if cached is not None:
174 self._cache.move_to_end(timestamp) 186 self._cache.move_to_end(timestamp)
...@@ -181,7 +193,6 @@ class HistoryListModel(QAbstractListModel): ...@@ -181,7 +193,6 @@ class HistoryListModel(QAbstractListModel):
181 if item is None: 193 if item is None:
182 placeholder = { 194 placeholder = {
183 'item': None, 195 'item': None,
184 'icon': self._build_placeholder_icon("已删除"),
185 'display_text': f"{timestamp}\n(已删除)", 196 'display_text': f"{timestamp}\n(已删除)",
186 'tooltip': f"时间戳: {timestamp}\n记录已不存在", 197 'tooltip': f"时间戳: {timestamp}\n记录已不存在",
187 } 198 }
...@@ -196,11 +207,8 @@ class HistoryListModel(QAbstractListModel): ...@@ -196,11 +207,8 @@ class HistoryListModel(QAbstractListModel):
196 f"宽高比: {item.aspect_ratio}\n" 207 f"宽高比: {item.aspect_ratio}\n"
197 f"尺寸: {item.image_size}" 208 f"尺寸: {item.image_size}"
198 ) 209 )
199
200 icon = self._load_thumbnail_icon(item)
201 rendered = { 210 rendered = {
202 'item': item, 211 'item': item,
203 'icon': icon,
204 'display_text': display_text, 212 'display_text': display_text,
205 'tooltip': tooltip, 213 'tooltip': tooltip,
206 } 214 }
......
...@@ -201,7 +201,32 @@ def enable_crash_diagnostics() -> None: ...@@ -201,7 +201,32 @@ def enable_crash_diagnostics() -> None:
201 201
202 sys.excepthook = _global_excepthook 202 sys.excepthook = _global_excepthook
203 203
204 # 3. Qt 消息拦截器 204 # 3. 子线程未捕获异常钩子 (Python 3.8+)
205 # sys.excepthook 只在主线程生效, QThread / threading.Thread 里逃出 try/except
206 # 的异常默认无声丢失 (Thread._bootstrap_inner 内部吞掉, 不调用 sys.excepthook).
207 # ImageGenerationWorker / audit UploadWorker / history 后台任务都需要这层兜底,
208 # 否则 worker 线程崩溃时 crash_log / app.log 都看不到 Python 栈.
209 def _thread_excepthook(args):
210 # SystemExit 走标准库语义: 正常退出, 不当 bug
211 if args.exc_type is SystemExit:
212 return
213 msg = "".join(traceback.format_exception(
214 args.exc_type, args.exc_value, args.exc_traceback
215 ))
216 thread_name = args.thread.name if args.thread else "<unknown>"
217 logging.critical(f"线程 [{thread_name}] 未捕获异常:\n{msg}")
218 try:
219 with open(crash_log, "a", encoding="utf-8") as f:
220 f.write(
221 f"\n[THREAD UNCAUGHT] {datetime.now().isoformat()} "
222 f"thread={thread_name}\n{msg}\n"
223 )
224 except Exception:
225 pass
226
227 threading.excepthook = _thread_excepthook
228
229 # 4. Qt 消息拦截器
205 try: 230 try:
206 from PySide6.QtCore import qInstallMessageHandler, QtMsgType 231 from PySide6.QtCore import qInstallMessageHandler, QtMsgType
207 232
......
...@@ -49,6 +49,7 @@ from core.jewelry import JewelryLibraryManager # noqa: E402 ...@@ -49,6 +49,7 @@ from core.jewelry import JewelryLibraryManager # noqa: E402
49 from core.runtime import ( # noqa: E402 49 from core.runtime import ( # noqa: E402
50 cleanup_clipboard_tempfiles, 50 cleanup_clipboard_tempfiles,
51 enable_crash_diagnostics, 51 enable_crash_diagnostics,
52 flush_logs,
52 init_logging, 53 init_logging,
53 log_system_info, 54 log_system_info,
54 ) 55 )
...@@ -302,6 +303,23 @@ def main(): ...@@ -302,6 +303,23 @@ def main():
302 # Phase 7: QML 装载 303 # Phase 7: QML 装载
303 logger.info("[BOOT] Phase 7: 装载 QML…") 304 logger.info("[BOOT] Phase 7: 装载 QML…")
304 engine = QQmlApplicationEngine() 305 engine = QQmlApplicationEngine()
306
307 # 把 QML 引擎的 warnings / 对象创建失败 导到 logger.
308 # qInstallMessageHandler 只拦 C++ 侧 qDebug/qWarning/qCritical;
309 # QML 编译错误 / binding loop / 资源 404 走的是 QQmlEngine.warnings 信号,
310 # 不导出的话只打到 stderr, frozen windowed 模式下 stderr 不存在 = 无痕.
311 def _on_qml_warnings(errors):
312 for e in errors:
313 logger.error(
314 f"[QML] {e.url().toString()}:{e.line()}:{e.column()} - {e.description()}"
315 )
316
317 def _on_qml_object_failed(url):
318 logger.error(f"[QML] 对象创建失败: {url.toString()}")
319
320 engine.warnings.connect(_on_qml_warnings)
321 engine.objectCreationFailed.connect(_on_qml_object_failed)
322
305 ctx = engine.rootContext() 323 ctx = engine.rootContext()
306 ctx.setContextProperty("appState", app_state) 324 ctx.setContextProperty("appState", app_state)
307 ctx.setContextProperty("auth", auth_bridge) 325 ctx.setContextProperty("auth", auth_bridge)
...@@ -320,13 +338,20 @@ def main(): ...@@ -320,13 +338,20 @@ def main():
320 338
321 # Phase 8: 退出 hook 339 # Phase 8: 退出 hook
322 def _flush_audit_on_quit(): 340 def _flush_audit_on_quit():
323 if audit_logger_inst is None: 341 # audit_logger 先 flush, 把队列里没 upload 的写盘
324 return 342 if audit_logger_inst is not None:
343 try:
344 audit_logger_inst.shutdown(timeout=5.0)
345 logger.info("[QUIT] audit logger 已 flush")
346 except Exception:
347 logger.exception("[QUIT] audit shutdown 失败")
348 # 再强制把 logging buffer fsync 到磁盘. TieredFsyncHandler 只对 WARNING+
349 # 即时刷盘, INFO/DEBUG 走 1s 周期兜底; 退出最后一秒的 INFO 不刷会丢.
350 # macOS aboutToQuit 后 Python 关闭顺序里 atexit/handler 可能被 SIGKILL 截断.
325 try: 351 try:
326 audit_logger_inst.shutdown(timeout=5.0) 352 flush_logs()
327 logger.info("[QUIT] audit logger 已 flush")
328 except Exception: 353 except Exception:
329 logger.exception("[QUIT] audit shutdown 失败") 354 pass
330 355
331 app.aboutToQuit.connect(_flush_audit_on_quit) 356 app.aboutToQuit.connect(_flush_audit_on_quit)
332 357
......
...@@ -266,6 +266,12 @@ class TaskQueueManager(QObject): ...@@ -266,6 +266,12 @@ class TaskQueueManager(QObject):
266 self.task_completed.emit(task_id, image_bytes, prompt, reference_images, 266 self.task_completed.emit(task_id, image_bytes, prompt, reference_images,
267 aspect_ratio, image_size, model) 267 aspect_ratio, image_size, model)
268 268
269 # emit 是 DirectConnection (sender/receiver 都在主线程), 下游 bridges
270 # 已完成 history 落盘并写回 task.result_path。内存里的 bytes 副本到此可以释放,
271 # 否则每条已完成 task 常驻 ~10-15MB (2K 图), 长跑后水位高 → OOM/SIGKILL.
272 # sidebar 点击已完成任务回显走 task.result_path 文件路径, 不依赖 bytes.
273 task.result_bytes = None
274
269 # 清理旧任务历史,只保留最近的完成任务 275 # 清理旧任务历史,只保留最近的完成任务
270 self._cleanup_old_tasks() 276 self._cleanup_old_tasks()
271 277
......