f48ebc4d by 柴进

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 验证。
1 parent e008166d
...@@ -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,11 +28,72 @@ def flush_logs() -> None: ...@@ -25,11 +28,72 @@ 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
39 except Exception:
40 pass
41 except Exception:
42 pass
43
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())
28 except Exception: 90 except Exception:
29 pass 91 pass
30 except Exception: 92 except Exception:
31 pass 93 pass
32 94
95 threading.Thread(target=_loop, daemon=True, name="log-fsync-loop").start()
96
33 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 """清理遗留的剪贴板临时文件,防止长时间运行累积到磁盘/句柄上限。
...@@ -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
373 handlers = [TieredFsyncHandler(log_file, maxBytes=max_bytes,
296 backupCount=backup_count, encoding='utf-8')] 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:
......