历史多触发闪退三面修:消除 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>
Showing
4 changed files
with
73 additions
and
9 deletions
| ... | @@ -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: |
| 325 | try: | 343 | try: |
| 326 | audit_logger_inst.shutdown(timeout=5.0) | 344 | audit_logger_inst.shutdown(timeout=5.0) |
| 327 | logger.info("[QUIT] audit logger 已 flush") | 345 | logger.info("[QUIT] audit logger 已 flush") |
| 328 | except Exception: | 346 | except Exception: |
| 329 | logger.exception("[QUIT] audit shutdown 失败") | 347 | logger.exception("[QUIT] audit shutdown 失败") |
| 348 | # 再强制把 logging buffer fsync 到磁盘. TieredFsyncHandler 只对 WARNING+ | ||
| 349 | # 即时刷盘, INFO/DEBUG 走 1s 周期兜底; 退出最后一秒的 INFO 不刷会丢. | ||
| 350 | # macOS aboutToQuit 后 Python 关闭顺序里 atexit/handler 可能被 SIGKILL 截断. | ||
| 351 | try: | ||
| 352 | flush_logs() | ||
| 353 | except Exception: | ||
| 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 | ... | ... |
-
Please register or sign in to post a comment