feat(packaging+log): 切 QML 主入口打包 + 分级 fsync 日志兜底
打包改造(task #19 Windows 部分):
- spec Analysis 入口:image_generator.py → qml_poc/main_qml.py
- pathex 加项目根,让 PyInstaller 扫到 audit_logger / task_queue /
config_util / preflight / bridges / core 等顶层模块
(main_qml.py 顶部 sys.path.insert 静态分析看不到)
- datas 加:
- qml_poc/qml → _MEIPASS/qml/(注意目标路径不带 qml_poc/ 前缀,
因为 PyInstaller frozen 把入口脚本平铺到 _MEIPASS 根,
main_qml.py 用 Path(__file__).parent / "qml" 找的是 _MEIPASS/qml/)
- jewelry_library.json(默认词库)
- zb100_mac.icns(Mac fallback)
- hiddenimports 显式列 PySide6.QtQml/QtQuick/QtQuickControls2 兜底
日志兜底(task #22):
- TieredFsyncHandler 替代 RotatingFileHandler:
WARNING+ emit 即 fsync(一条不丢)
INFO 走 buffered(零开销)
- start_periodic_fsync daemon 线程每 1s fsync 一次(INFO 最坏丢 1s)
- enable_crash_diagnostics 启动时在 crash_log.txt 写 macOS native crash
提示路径:~/Library/Logs/DiagnosticReports/ZB100ImageGenerator-*.ips
- flush_logs() 升级,对所有 handlers 也走 fsync
Windows 打包验证:dist/ZB100ImageGenerator/ZB100ImageGenerator.exe
启动 8 phases 全过、QML 装载成功、进入主循环零警告。
Mac 打包待 build_mac_universal.sh 验证。
Showing
2 changed files
with
111 additions
and
9 deletions
| ... | @@ -44,9 +44,20 @@ else: | ... | @@ -44,9 +44,20 @@ else: |
| 44 | ICON = None | 44 | ICON = None |
| 45 | 45 | ||
| 46 | # ----- 数据文件 ----- | 46 | # ----- 数据文件 ----- |
| 47 | datas = [('config.json', '.')] | 47 | # QML 主入口跑时按 `Path(__file__).parent / "qml"` 加载, |
| 48 | # 而 PyInstaller frozen 把入口脚本平铺到 _MEIPASS/ 根(不带 qml_poc/ 前缀), | ||
| 49 | # 所以 QML 目标路径必须是 _MEIPASS/qml/,不是 _MEIPASS/qml_poc/qml/。 | ||
| 50 | # qmldir + Theme.qml Singleton 注册依赖整个目录平铺过去。 | ||
| 51 | datas = [ | ||
| 52 | ('config.json', '.'), | ||
| 53 | ('qml_poc/qml', 'qml'), # 整个 QML 目录递归 → _MEIPASS/qml/ | ||
| 54 | ('jewelry_library.json', '.'), # 默认珠宝词库 | ||
| 55 | ] | ||
| 48 | if IS_WIN: | 56 | if IS_WIN: |
| 49 | datas.append(('zb100_windows.ico', '.')) | 57 | datas.append(('zb100_windows.ico', '.')) |
| 58 | if IS_MAC: | ||
| 59 | # macOS 走 BUNDLE 时 icon 由下方 BUNDLE() 处理,但开发期 fallback 也带上 | ||
| 60 | datas.append(('zb100_mac.icns', '.')) | ||
| 50 | 61 | ||
| 51 | # ===== Pillow 原生库收集 ===== | 62 | # ===== Pillow 原生库收集 ===== |
| 52 | 63 | ||
| ... | @@ -98,11 +109,20 @@ if IS_MAC and len(pil_native_libs) == 0: | ... | @@ -98,11 +109,20 @@ if IS_MAC and len(pil_native_libs) == 0: |
| 98 | print('=' * 60) | 109 | print('=' * 60) |
| 99 | 110 | ||
| 100 | a = Analysis( | 111 | a = Analysis( |
| 101 | ['image_generator.py'], | 112 | ['qml_poc/main_qml.py'], |
| 102 | pathex=[], | 113 | # main_qml.py 顶部用 sys.path.insert(0, ROOT) 把项目根加到搜索路径, |
| 114 | # 但 PyInstaller 静态分析看不到这一步 — 显式给 pathex 让它扫到 | ||
| 115 | # audit_logger / task_queue / config_util / preflight / bridges / core 等顶层模块。 | ||
| 116 | pathex=[str(Path('.').resolve())], | ||
| 103 | binaries=pil_native_libs, | 117 | binaries=pil_native_libs, |
| 104 | datas=datas, | 118 | datas=datas, |
| 105 | hiddenimports=[], | 119 | # PyInstaller 6.x 自动 hook PySide6 大部分子模块,这里显式列出兜底; |
| 120 | # QtQuick.Dialogs 等纯 QML 模块由 datas QML 目录间接拉入。 | ||
| 121 | hiddenimports=[ | ||
| 122 | 'PySide6.QtQml', | ||
| 123 | 'PySide6.QtQuick', | ||
| 124 | 'PySide6.QtQuickControls2', | ||
| 125 | ], | ||
| 106 | hookspath=[], | 126 | hookspath=[], |
| 107 | hooksconfig={}, | 127 | hooksconfig={}, |
| 108 | runtime_hooks=[], | 128 | runtime_hooks=[], | ... | ... |
| ... | @@ -10,13 +10,16 @@ import os | ... | @@ -10,13 +10,16 @@ import os |
| 10 | import platform | 10 | import platform |
| 11 | import sys | 11 | import sys |
| 12 | import tempfile | 12 | import tempfile |
| 13 | import threading | ||
| 14 | import time | ||
| 13 | import traceback | 15 | import traceback |
| 14 | from datetime import datetime | 16 | from datetime import datetime |
| 17 | from logging.handlers import RotatingFileHandler | ||
| 15 | from pathlib import Path | 18 | from pathlib import Path |
| 16 | 19 | ||
| 17 | 20 | ||
| 18 | def flush_logs() -> None: | 21 | def flush_logs() -> None: |
| 19 | """强制刷盘所有日志 handlers。 | 22 | """强制刷盘所有日志 handlers(包括 fsync 到磁盘)。 |
| 20 | 23 | ||
| 21 | macOS 对 UI 线程卡顿超过阈值会 SIGKILL,缓冲区未刷盘的日志会丢失, | 24 | macOS 对 UI 线程卡顿超过阈值会 SIGKILL,缓冲区未刷盘的日志会丢失, |
| 22 | 下次崩溃定位不到。在可疑阶段边界调用此函数保证日志落盘。 | 25 | 下次崩溃定位不到。在可疑阶段边界调用此函数保证日志落盘。 |
| ... | @@ -25,12 +28,73 @@ def flush_logs() -> None: | ... | @@ -25,12 +28,73 @@ def flush_logs() -> None: |
| 25 | for h in logging.getLogger().handlers: | 28 | for h in logging.getLogger().handlers: |
| 26 | try: | 29 | try: |
| 27 | h.flush() | 30 | h.flush() |
| 31 | # 在普通 RotatingFileHandler 上也强制 fsync — | ||
| 32 | # flush() 只把 Python buffer 推到 OS,fsync 才进磁盘 | ||
| 33 | stream = getattr(h, "stream", None) | ||
| 34 | if stream and hasattr(stream, "fileno"): | ||
| 35 | try: | ||
| 36 | os.fsync(stream.fileno()) | ||
| 37 | except (OSError, ValueError): | ||
| 38 | pass | ||
| 28 | except Exception: | 39 | except Exception: |
| 29 | pass | 40 | pass |
| 30 | except Exception: | 41 | except Exception: |
| 31 | pass | 42 | pass |
| 32 | 43 | ||
| 33 | 44 | ||
| 45 | class TieredFsyncHandler(RotatingFileHandler): | ||
| 46 | """分级落盘的 RotatingFileHandler。 | ||
| 47 | |||
| 48 | Why: 每条 emit 都 fsync 是大锤打蚊子(性能浪费);完全不 fsync 则 macOS | ||
| 49 | SIGKILL / 系统崩溃时丢日志。诊断价值越高的日志,落盘保障越严: | ||
| 50 | |||
| 51 | - WARNING 及以上:emit 即 fsync(一条不丢,logger.exception 必然到磁盘) | ||
| 52 | - INFO 及以下:走默认 buffered 写入(零开销,性能优先) | ||
| 53 | |||
| 54 | 搭配后台 _periodic_fsync_loop daemon 线程,普通日志最坏丢 1s。 | ||
| 55 | """ | ||
| 56 | def emit(self, record): | ||
| 57 | super().emit(record) | ||
| 58 | if record.levelno >= logging.WARNING: | ||
| 59 | try: | ||
| 60 | if self.stream: | ||
| 61 | self.stream.flush() | ||
| 62 | os.fsync(self.stream.fileno()) | ||
| 63 | except Exception: | ||
| 64 | pass | ||
| 65 | |||
| 66 | |||
| 67 | _fsync_thread_started = False | ||
| 68 | |||
| 69 | |||
| 70 | def start_periodic_fsync(interval_seconds: float = 1.0) -> None: | ||
| 71 | """启动后台周期 fsync daemon 线程,确保 INFO 级别日志最坏丢 1s。 | ||
| 72 | |||
| 73 | 幂等:重复调用只启动一次。daemon=True 进程退出自动结束。 | ||
| 74 | """ | ||
| 75 | global _fsync_thread_started | ||
| 76 | if _fsync_thread_started: | ||
| 77 | return | ||
| 78 | _fsync_thread_started = True | ||
| 79 | |||
| 80 | def _loop(): | ||
| 81 | while True: | ||
| 82 | time.sleep(interval_seconds) | ||
| 83 | try: | ||
| 84 | for h in logging.getLogger().handlers: | ||
| 85 | stream = getattr(h, "stream", None) | ||
| 86 | if stream and hasattr(stream, "fileno"): | ||
| 87 | try: | ||
| 88 | h.flush() | ||
| 89 | os.fsync(stream.fileno()) | ||
| 90 | except Exception: | ||
| 91 | pass | ||
| 92 | except Exception: | ||
| 93 | pass | ||
| 94 | |||
| 95 | threading.Thread(target=_loop, daemon=True, name="log-fsync-loop").start() | ||
| 96 | |||
| 97 | |||
| 34 | def cleanup_clipboard_tempfiles(max_age_hours: int = 24) -> int: | 98 | def cleanup_clipboard_tempfiles(max_age_hours: int = 24) -> int: |
| 35 | """清理遗留的剪贴板临时文件,防止长时间运行累积到磁盘/句柄上限。 | 99 | """清理遗留的剪贴板临时文件,防止长时间运行累积到磁盘/句柄上限。 |
| 36 | 100 | ||
| ... | @@ -87,7 +151,21 @@ def enable_crash_diagnostics() -> None: | ... | @@ -87,7 +151,21 @@ def enable_crash_diagnostics() -> None: |
| 87 | crash_fh = open(crash_log, "a", encoding="utf-8") | 151 | crash_fh = open(crash_log, "a", encoding="utf-8") |
| 88 | crash_fh.write(f"\n{'=' * 60}\n") | 152 | crash_fh.write(f"\n{'=' * 60}\n") |
| 89 | crash_fh.write(f"[STARTUP] {datetime.now().isoformat()} - faulthandler 已启用\n") | 153 | crash_fh.write(f"[STARTUP] {datetime.now().isoformat()} - faulthandler 已启用\n") |
| 154 | # macOS native crash 提示:faulthandler 只能给 Python 栈;C++ 崩溃栈在系统 crash report | ||
| 155 | if sys.platform == "darwin": | ||
| 156 | crash_fh.write( | ||
| 157 | "[HINT] 若启动后立刻闪退、本文件无 Python 栈,说明是 native C++ 崩溃。\n" | ||
| 158 | " 系统 crash report 路径:\n" | ||
| 159 | " ~/Library/Logs/DiagnosticReports/ZB100ImageGenerator-*.ips\n" | ||
| 160 | " ~/Library/Logs/DiagnosticReports/ZB100ImageGenerator-*.crash\n" | ||
| 161 | " 也可在「控制台」app → 左侧「崩溃报告」中查看。\n" | ||
| 162 | " 上报问题请同时附 logs/app.log 和系统 crash report。\n" | ||
| 163 | ) | ||
| 90 | crash_fh.flush() | 164 | crash_fh.flush() |
| 165 | try: | ||
| 166 | os.fsync(crash_fh.fileno()) # 启动期立即 fsync — 防 native 崩溃前丢这段元信息 | ||
| 167 | except Exception: | ||
| 168 | pass | ||
| 91 | faulthandler.enable(file=crash_fh, all_threads=True) | 169 | faulthandler.enable(file=crash_fh, all_threads=True) |
| 92 | # 同时输出到 stderr (仅在 stderr 真实存在时;windowed build 下跳过) | 170 | # 同时输出到 stderr (仅在 stderr 真实存在时;windowed build 下跳过) |
| 93 | if sys.stderr is not None: | 171 | if sys.stderr is not None: |
| ... | @@ -289,16 +367,20 @@ def init_logging(log_level=logging.INFO) -> bool: | ... | @@ -289,16 +367,20 @@ def init_logging(log_level=logging.INFO) -> bool: |
| 289 | log_file = logs_dir / "app.log" | 367 | log_file = logs_dir / "app.log" |
| 290 | log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" | 368 | log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" |
| 291 | 369 | ||
| 292 | from logging.handlers import RotatingFileHandler | ||
| 293 | max_bytes = int(logging_config.get("max_bytes", 5 * 1024 * 1024)) | 370 | max_bytes = int(logging_config.get("max_bytes", 5 * 1024 * 1024)) |
| 294 | backup_count = int(logging_config.get("backup_count", 5)) | 371 | backup_count = int(logging_config.get("backup_count", 5)) |
| 295 | handlers = [RotatingFileHandler(log_file, maxBytes=max_bytes, | 372 | # TieredFsyncHandler:WARNING+ 同步 fsync,INFO 走 buffered + 后台周期 fsync |
| 296 | backupCount=backup_count, encoding='utf-8')] | 373 | handlers = [TieredFsyncHandler(log_file, maxBytes=max_bytes, |
| 374 | backupCount=backup_count, encoding='utf-8')] | ||
| 297 | if logging_config.get("log_to_console", True): | 375 | if logging_config.get("log_to_console", True): |
| 298 | handlers.append(logging.StreamHandler()) | 376 | handlers.append(logging.StreamHandler()) |
| 299 | 377 | ||
| 300 | logging.basicConfig(level=log_level, format=log_format, handlers=handlers, force=True) | 378 | logging.basicConfig(level=log_level, format=log_format, handlers=handlers, force=True) |
| 301 | logging.info(f"日志系统初始化完成 - 级别: {level_str}, 文件: {log_file}") | 379 | |
| 380 | # 启动后台周期 fsync 线程(兜底 INFO 级别日志最坏丢 1s) | ||
| 381 | start_periodic_fsync(interval_seconds=1.0) | ||
| 382 | |||
| 383 | logging.info(f"日志系统初始化完成 - 级别: {level_str}, 文件: {log_file}(分级 fsync + 1s 周期兜底)") | ||
| 302 | return True | 384 | return True |
| 303 | 385 | ||
| 304 | except Exception as e: | 386 | except Exception as e: | ... | ... |
-
Please register or sign in to post a comment