Skip to content

fix(macos): 重打包级补丁在 macOS 全链路可用——看护启动崩溃 / asar 写锁 / crashpad 泄漏误判退出 / 测试修复 - #4

Open
X-Shao wants to merge 6 commits into
c80361619:mainfrom
X-Shao:fix/macos-watchdog-lock-detect
Open

X-Shao wants to merge 6 commits into
c80361619:mainfrom
X-Shao:fix/macos-watchdog-lock-detect

Conversation

@X-Shao

@X-Shao X-Shao commented Sep 24, 2026 •

Copy link
Copy Markdown

现象

macOS 上 TPS 状态栏 / 思考滑条 / 模型拉取 / 增强提示词这 4 项重打包级补丁永远不生效:退出并重启 ZCode 后依然「未打」,doctor.py 一路全绿、不报任何错。

根因(四层,逐层剥开)

1. 看护进程在 macOS 上启动即崩(无声)

apply_after_exit.zcode_running() 直接跑 ["tasklist"] 检索 b"ZCode.exe"——Windows 专属。macOS/Linux 上 FileNotFoundError 未被捕获(except 只接 TimeoutExpired),看护第一次轮询就崩;启动方又把 stderr 定向 DEVNULL,崩溃完全无声。日志表现为「看护启动 N 次 / 等到退出 0 次 / 完成 0 次」。

2. asar 跨进程写锁无条件 import msvcrt

_AsarWriteLock 的注释写着「本工具只在 Windows 注入客户端」,但插件明确支持 macOS(/Applications/ZCode.app)。于是 4 项重打包级补丁全部 ModuleNotFoundError: No module named 'msvcrt' 失败——第 1 层修好、看护真正开始执行时才暴露。

3. crashpad 泄漏让 pgrep 永远非零

macOS 上 ZCode 每次退出都会泄漏一个 chrome_crashpad_handler(ppid=1,命令行仍含 ZCode.app 路径)。pgrep -f ZCode 因此永远命中——用户明明已完全退出,看护仍判「在运行」,直到 24h 超时放弃。本机实测堆积过 3.9.1(8 月 24 日)/ 3.10.1(8 月 28 日)时代的泄漏进程,跨版本存活一个多月。这一层决定了「修好前两层之后补丁依然写不进去」。

顺带:pgrep -x ZCode 在 macOS 上匹配不到主进程(主进程 argv 为裸 ZCode,与 comm 不一致)。本 PR 不依赖 -x,改为逐 PID 核对命令行。

4. 探针 _from_running_processes 在 POSIX 上跑单测必炸

Path("C:\\...") 在 macOS/Linux 抛 NotImplementedError(单测把 os.name 伪造成 nt,而 zp.os 即全局 os 模块单例,Path() 因此尝试构造 WindowsPath)。

修复(6 个 commit)

commit 内容
a1c4102 看护 POSIX 兼容:zcode_running 复用跨平台检测、_find_exe(macOS .app 包 / Linux 小写命名)、重启命令按平台分流、start_new_session 后台化(否则看护留在钩子进程组,应用退出时被整组信号连带杀掉)、崩溃写日志、Popen 补 no_window_kwargs
8173cbd 写锁 Windows msvcrt.locking / POSIX fcntl.flock 分流;探针存 PureWindowsPath,discover() 里 map(Path, roots) 转回(Windows 真实运行路径行为不变)
5492539 测试修复:stale 备份 glob 排除 .meta.json——修复一个上游 flaky:_archive_backup 把 bak 与 bak.meta.json 一起改名成 stale-*,两者都命中 glob 模式,APFS 目录枚举顺序随时间戳哈希变化,stale[0] 取到谁是随机的(实测同代码 10 跑 4~6 挂);修复后 20 连跑全绿
098f2ff zcode_running 对 pgrep 命中的 PID 逐个 ps 核对命令行,排除 crashpad;应用真正运行时主进程 / ZCode Helper 必然在列,不受影响
b21dead 评审跟进(Copilot 指出):_import_patcher() 守卫 sys.path 插入——看护轮询每 3s 调一次,原写法 24h 会堆积约 2.9 万个重复项;附回归测试(连续调用 200 次 sys.path 长度不变)
3addc75 自查修复:Windows 重启分支恢复上游原样的 creationflags=DETACHED_PROCESS(b21dead 前为过静态检查误改为 CREATE_NO_WINDOW,与"Windows 行为零变化"承诺不符)

测试

  • python -m unittest discover -s tests:275 个全绿(2 个 Windows-only skip),含新增 7 个(fake pgrep/ps 四种情形、真实 ps 集成、纯函数注入、sys.path 守卫回归;其中「pgrep 命中后进程消失按已退出处理」用例在实现阶段抓到了 None 分支误判存活的真 bug,已修)
  • 锁冒烟(真进程):子进程持锁 3s,主进程 1s 超时正确 TimeoutError;释放后重新获取 ✓
  • 真机模拟(真 pgrep/ps + python 伪 crashpad 进程):仅僵尸命中 → False;僵尸 + 真实 helper 混合 → True;端到端 zcode_running() → True ✓
  • 端到端实证(macOS 实机):第 1+2 层修复后走完「完全退出 → 看护自动写入全部补丁 → 自动重启 → TPS 状态栏显示 tok/s」全链路;第 3 层修复前需退出后手动 pkill 清 crashpad 才能触发写入,修复后无需任何手动步骤

验证环境:macOS arm64(darwin 25.6.0)、ZCode 3.14.3、Python 3.11.8。

备注

Windows 行为零变化:tasklist 分支、msvcrt.locking 路径、no_window_kwargs 语义均保持原样;新增的 ps 逐 PID 核对只在 POSIX 分支执行。

- zcode_running 复用 zcode_patcher 跨平台检测:原实现直接跑 tasklist(Windows 专属),
  macOS/Linux 上 FileNotFoundError 未捕获,看护第一次轮询即崩,且 stderr 被 DEVNULL
  吞掉,表现为「等退出 0 次、完成 0 次」,重打包级补丁永远写不进去
- resolve_install 新增 _find_exe:macOS 取 Contents/MacOS/ZCode,Linux 尝试小写命名
- 重启命令按平台分流:Windows creationflags / macOS open <bundle> / 其他 start_new_session
- sync.start_watchdog POSIX 加 start_new_session=True(否则看护留在钩子进程组,
  应用退出时被整组信号连带杀掉)
- __main__ 加 try/except 写日志,崩溃不再无声
- 全部 Popen 补齐 no_window_kwargs()/no-window-ok 标注
- _AsarWriteLock 跨进程文件锁原来无条件 import msvcrt(注释误以为只在 Windows
  注入客户端),macOS/Linux 上 4 项重打包级补丁全部 ModuleNotFoundError 失败;
  改为 Windows msvcrt.locking / POSIX fcntl.flock 分流
- _from_running_processes 存 PureWindowsPath:.exe 行是 Windows 路径,POSIX 上
  Path("C:...") 抛 NotImplementedError;discover() 消费时 map(Path, roots) 转回
_archive_backup 把 bak 与 bak.meta.json 一起改名成 stale-*,两者都命中
glob 模式;目录枚举顺序随时间戳哈希变化,stale[0] 取到谁是随机的——
实测同代码 10 跑 4~6 挂。排除 .meta.json 后 20 连跑全绿。
macOS 实测:ZCode 每次退出都会泄漏一个 chrome_crashpad_handler(ppid=1,
命令行仍含 ZCode.app 路径),pgrep -f ZCode 因此永远非零——用户明明已
完全退出,看护却判定「仍在运行」,重打包级补丁永远写不进去。本机曾堆积
3.9.1 / 3.10.1 时代的多个泄漏进程。

修法:对 pgrep 命中的 PID 逐个 ps 核对命令行,crashpad 类不算存活信号;
应用真正运行时主进程 / ZCode Helper 必然在列,不受影响。

新增 6 个单测(fake pgrep/ps + 真实 ps 集成 + 纯函数注入):
- pgrep 无命中 → False
- 仅 crashpad 僵尸(跨版本堆积)→ False
- crashpad + 真实 helper 并存 → True
- pgrep 命中后进程消失(ps 查不到)→ 按已退出处理(此用例在实现阶段
  抓到了 None 分支误判存活的真 bug,已修)
- _pid_commandline 真实子进程集成
- _pgrep_hits_are_alive 注入 cmdline_of 的纯函数测试

另经本机真机模拟验证:python 伪 crashpad 进程(argv 含 ZCode+crashpad
标记)+ 真 pgrep/ps,仅僵尸判 False、混入真实 helper 判 True、端到端判 True。
Copilot AI lite review requested due to automatic review settings September 24, 2026 07:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

apply_after_exit.py currently unconditionally prepends HERE to sys.path during polling/install discovery, which can grow sys.path without bound in the watchdog loop and should be guarded to prevent long-running degradation.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 Medium severity

Open (1)
What changed in this PR

This PR fixes macOS end-to-end usability of the “apply after exit” repack-level patches by making the watchdog and patcher cross-platform (POSIX) and by correcting process-detection so leaked chrome_crashpad_handler processes don’t block patch application.

Changes:

  • Add POSIX-aware zcode_running() logic that validates pgrep hits via per-PID ps commandline checks (filters crashpad leaks).
  • Make the asar write lock cross-platform (Windows msvcrt.locking vs POSIX fcntl.flock) and fix path handling in probes/tests.
  • Fix test flakiness around stale backup globbing and add POSIX-specific tests for zcode_running() behavior.
File Description
tests/​test_patcher.py Fixes flaky stale-backup assertions and adds POSIX tests for crashpad-filtered running detection.
skills/​zcode-tokenspeed/​scripts/​zcode_patcher.py Adds PID commandline inspection for POSIX zcode_running, uses PureWindowsPath to avoid WindowsPath construction issues, and splits file locking by platform.
skills/​zcode-tokenspeed/​scripts/​sync.py Ensures the watchdog is spawned in a detached session on POSIX so it survives app exit/cleanup signals.
skills/​zcode-tokenspeed/​scripts/​apply_after_exit.py Reuses patcher’s cross-platform running detection, improves restart behavior per-platform, and logs fatal crashes for unattended watchdog runs.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +54 to +56
sys.path.insert(0, str(HERE))
import zcode_patcher as zp
return zp.zcode_running()
看护轮询每 3s 调一次 zcode_running(),原写法每次 sys.path.insert(0, HERE),
24h 上限约 2.9 万个重复项。改为只在缺失时插入,两处调用点(zcode_running /
resolve_install)统一走 _import_patcher()。附回归测试:连续调用 200 次
sys.path 长度不变、模块对象复用缓存。

实际影响说明:import zcode_patcher 第二次起命中 sys.modules 缓存,不会逐项
扫描 sys.path,故原问题主要是内存/整洁性而非查找性能——但仍应修。
@X-Shao

X-Shao commented Sep 24, 2026

Copy link
Copy Markdown
Author

感谢指出,已在 b21dead 修复:两处调用点统一走 _import_patcher()(仅缺失时插入),并加回归测试(连续调用 200 次 sys.path 长度不变)。补充说明实际影响:import zcode_patcher 首次后命中 sys.modules 缓存,轮询并不会逐项扫描 sys.path,因此原问题主要是内存增长与代码整洁性,而非 import 查找性能——但仍应修,已修。

自查发现:上一提交为过 no-window 静态检查把它改成 **no_window_kwargs()
(CREATE_NO_WINDOW),与上游的 DETACHED_PROCESS 不同——违背本 PR
「Windows 行为零变化」的承诺。静态检查本来就接受 creationflags 字面量,
无需改值。GUI 程序下两者效果几乎一致,但保持逐字节同语义更诚实。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants