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:
ICON = None
# ----- 数据文件 -----
datas = [('config.json', '.')]
# QML 主入口跑时按 `Path(__file__).parent / "qml"` 加载,
# 而 PyInstaller frozen 把入口脚本平铺到 _MEIPASS/ 根(不带 qml_poc/ 前缀),
# 所以 QML 目标路径必须是 _MEIPASS/qml/,不是 _MEIPASS/qml_poc/qml/。
# qmldir + Theme.qml Singleton 注册依赖整个目录平铺过去。
datas = [
('config.json', '.'),
('qml_poc/qml', 'qml'), # 整个 QML 目录递归 → _MEIPASS/qml/
('jewelry_library.json', '.'), # 默认珠宝词库
]
if IS_WIN:
datas.append(('zb100_windows.ico', '.'))
if IS_MAC:
# macOS 走 BUNDLE 时 icon 由下方 BUNDLE() 处理,但开发期 fallback 也带上
datas.append(('zb100_mac.icns', '.'))
# ===== Pillow 原生库收集 =====
......@@ -98,11 +109,20 @@ if IS_MAC and len(pil_native_libs) == 0:
print('=' * 60)
a = Analysis(
['image_generator.py'],
pathex=[],
['qml_poc/main_qml.py'],
# main_qml.py 顶部用 sys.path.insert(0, ROOT) 把项目根加到搜索路径,
# 但 PyInstaller 静态分析看不到这一步 — 显式给 pathex 让它扫到
# audit_logger / task_queue / config_util / preflight / bridges / core 等顶层模块。
pathex=[str(Path('.').resolve())],
binaries=pil_native_libs,
datas=datas,
hiddenimports=[],
# PyInstaller 6.x 自动 hook PySide6 大部分子模块,这里显式列出兜底;
# QtQuick.Dialogs 等纯 QML 模块由 datas QML 目录间接拉入。
hiddenimports=[
'PySide6.QtQml',
'PySide6.QtQuick',
'PySide6.QtQuickControls2',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
......
......@@ -10,13 +10,16 @@ import os
import platform
import sys
import tempfile
import threading
import time
import traceback
from datetime import datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path
def flush_logs() -> None:
"""强制刷盘所有日志 handlers。
"""强制刷盘所有日志 handlers(包括 fsync 到磁盘)
macOS 对 UI 线程卡顿超过阈值会 SIGKILL,缓冲区未刷盘的日志会丢失,
下次崩溃定位不到。在可疑阶段边界调用此函数保证日志落盘。
......@@ -25,11 +28,72 @@ def flush_logs() -> None:
for h in logging.getLogger().handlers:
try:
h.flush()
# 在普通 RotatingFileHandler 上也强制 fsync —
# flush() 只把 Python buffer 推到 OS,fsync 才进磁盘
stream = getattr(h, "stream", None)
if stream and hasattr(stream, "fileno"):
try:
os.fsync(stream.fileno())
except (OSError, ValueError):
pass
except Exception:
pass
except Exception:
pass
class TieredFsyncHandler(RotatingFileHandler):
"""分级落盘的 RotatingFileHandler。
Why: 每条 emit 都 fsync 是大锤打蚊子(性能浪费);完全不 fsync 则 macOS
SIGKILL / 系统崩溃时丢日志。诊断价值越高的日志,落盘保障越严:
- WARNING 及以上:emit 即 fsync(一条不丢,logger.exception 必然到磁盘)
- INFO 及以下:走默认 buffered 写入(零开销,性能优先)
搭配后台 _periodic_fsync_loop daemon 线程,普通日志最坏丢 1s。
"""
def emit(self, record):
super().emit(record)
if record.levelno >= logging.WARNING:
try:
if self.stream:
self.stream.flush()
os.fsync(self.stream.fileno())
except Exception:
pass
_fsync_thread_started = False
def start_periodic_fsync(interval_seconds: float = 1.0) -> None:
"""启动后台周期 fsync daemon 线程,确保 INFO 级别日志最坏丢 1s。
幂等:重复调用只启动一次。daemon=True 进程退出自动结束。
"""
global _fsync_thread_started
if _fsync_thread_started:
return
_fsync_thread_started = True
def _loop():
while True:
time.sleep(interval_seconds)
try:
for h in logging.getLogger().handlers:
stream = getattr(h, "stream", None)
if stream and hasattr(stream, "fileno"):
try:
h.flush()
os.fsync(stream.fileno())
except Exception:
pass
except Exception:
pass
threading.Thread(target=_loop, daemon=True, name="log-fsync-loop").start()
def cleanup_clipboard_tempfiles(max_age_hours: int = 24) -> int:
"""清理遗留的剪贴板临时文件,防止长时间运行累积到磁盘/句柄上限。
......@@ -87,7 +151,21 @@ def enable_crash_diagnostics() -> None:
crash_fh = open(crash_log, "a", encoding="utf-8")
crash_fh.write(f"\n{'=' * 60}\n")
crash_fh.write(f"[STARTUP] {datetime.now().isoformat()} - faulthandler 已启用\n")
# macOS native crash 提示:faulthandler 只能给 Python 栈;C++ 崩溃栈在系统 crash report
if sys.platform == "darwin":
crash_fh.write(
"[HINT] 若启动后立刻闪退、本文件无 Python 栈,说明是 native C++ 崩溃。\n"
" 系统 crash report 路径:\n"
" ~/Library/Logs/DiagnosticReports/ZB100ImageGenerator-*.ips\n"
" ~/Library/Logs/DiagnosticReports/ZB100ImageGenerator-*.crash\n"
" 也可在「控制台」app → 左侧「崩溃报告」中查看。\n"
" 上报问题请同时附 logs/app.log 和系统 crash report。\n"
)
crash_fh.flush()
try:
os.fsync(crash_fh.fileno()) # 启动期立即 fsync — 防 native 崩溃前丢这段元信息
except Exception:
pass
faulthandler.enable(file=crash_fh, all_threads=True)
# 同时输出到 stderr (仅在 stderr 真实存在时;windowed build 下跳过)
if sys.stderr is not None:
......@@ -289,16 +367,20 @@ def init_logging(log_level=logging.INFO) -> bool:
log_file = logs_dir / "app.log"
log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
from logging.handlers import RotatingFileHandler
max_bytes = int(logging_config.get("max_bytes", 5 * 1024 * 1024))
backup_count = int(logging_config.get("backup_count", 5))
handlers = [RotatingFileHandler(log_file, maxBytes=max_bytes,
# TieredFsyncHandler:WARNING+ 同步 fsync,INFO 走 buffered + 后台周期 fsync
handlers = [TieredFsyncHandler(log_file, maxBytes=max_bytes,
backupCount=backup_count, encoding='utf-8')]
if logging_config.get("log_to_console", True):
handlers.append(logging.StreamHandler())
logging.basicConfig(level=log_level, format=log_format, handlers=handlers, force=True)
logging.info(f"日志系统初始化完成 - 级别: {level_str}, 文件: {log_file}")
# 启动后台周期 fsync 线程(兜底 INFO 级别日志最坏丢 1s)
start_periodic_fsync(interval_seconds=1.0)
logging.info(f"日志系统初始化完成 - 级别: {level_str}, 文件: {log_file}(分级 fsync + 1s 周期兜底)")
return True
except Exception as e:
......