From a1c4102e8da533b727423415beb72234f5e5b00e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=B1=E5=9B=BD=E7=91=9E?= Date: Thu, 24 Sep 2026 15:47:19 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix(macos):=20=E7=9C=8B=E6=8A=A4=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=20POSIX=20=E5=85=BC=E5=AE=B9=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=B4=A9=E6=BA=83/=E9=87=8D=E5=90=AF?= =?UTF-8?q?=E5=88=86=E6=B5=81/=E5=90=8E=E5=8F=B0=E5=8C=96/=E6=97=A0?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 / 其他 start_new_session - sync.start_watchdog POSIX 加 start_new_session=True(否则看护留在钩子进程组, 应用退出时被整组信号连带杀掉) - __main__ 加 try/except 写日志,崩溃不再无声 - 全部 Popen 补齐 no_window_kwargs()/no-window-ok 标注 --- .../scripts/apply_after_exit.py | 66 ++++++++++++++----- skills/zcode-tokenspeed/scripts/sync.py | 12 ++-- 2 files changed, 57 insertions(+), 21 deletions(-) diff --git a/skills/zcode-tokenspeed/scripts/apply_after_exit.py b/skills/zcode-tokenspeed/scripts/apply_after_exit.py index 304b60f..545ade1 100644 --- a/skills/zcode-tokenspeed/scripts/apply_after_exit.py +++ b/skills/zcode-tokenspeed/scripts/apply_after_exit.py @@ -13,6 +13,7 @@ 退出码约定(与 zcode_patcher.py 一致):0=成功、1=有项目失败/等待超时、2=预检失败(ZCode 仍在运行)。 """ +import os import subprocess import sys import time @@ -42,19 +43,34 @@ def log(msg: str) -> None: def zcode_running() -> bool: - # 用 bytes 检索,不走 text 解码:tasklist 输出是 GBK,而本机 Python 为 UTF-8 - # 模式,text=True 会在读线程里抛 UnicodeDecodeError → stdout 变空 → 误判「已退出」。 - # 同理不带 /FI:从 Git Bash/MSYS 环境启动时 "/FI" 会被路径转换破坏。 - # 带 timeout:tasklist 在系统繁忙/WMI 打嗝时可能挂住,看护无人值守,不能无限等。 - # 超时视为「仍在运行」(保守:宁可多等一轮,也不要在 ZCode 还锁着 asar 时动手)。 + # 复用主脚本 zcode_patcher.zcode_running() 的跨平台检测(Windows: tasklist 检索 + # ZCode.exe;POSIX: pgrep -f ZCode)。看护的判定必须与补丁预检同源,否则会出现 + # 「看护以为退干净了、动手时又被预检拒绝」的永久错位。 + # 历史问题:这里曾直接跑 ["tasklist"] 并检索 b"ZCode.exe" —— macOS/Linux 上没有 + # tasklist,FileNotFoundError 未被捕获(except 只接 TimeoutExpired),看护在第一次 + # 轮询就崩溃;启动方又把 stderr 定向到 DEVNULL,崩溃完全无声,表现为 + # 「等退出 0 次、完成 0 次」、补丁永远写不进去。 try: - out = subprocess.run(["tasklist"], capture_output=True, - timeout=TASKLIST_TIMEOUT, - **no_window_kwargs()).stdout or b"" - except subprocess.TimeoutExpired: - log(f"tasklist 超时({TASKLIST_TIMEOUT}s),本轮按「仍在运行」处理") + sys.path.insert(0, str(HERE)) + import zcode_patcher as zp + return zp.zcode_running() + except Exception as e: + log(f"运行检测失败: {type(e).__name__}: {e},本轮按「仍在运行」处理") return True - return b"ZCode.exe" in out + + +def _find_exe(res: Path) -> Path | None: + """从 asar 所在目录(<安装根>/resources)推出各平台的 ZCode 主程序路径。""" + root = res.parent # Windows: 安装根;macOS: ZCode.app/Contents + cands: list[Path] = [root / "ZCode.exe"] # Windows + if sys.platform == "darwin": + cands.insert(0, root / "MacOS" / "ZCode") # macOS .app 包 + else: + cands += [root / "zcode", root / "ZCode"] # Linux 常见命名 + for c in cands: + if c.is_file(): + return c + return None def resolve_install() -> tuple[Path | None, Path | None]: @@ -65,8 +81,7 @@ def resolve_install() -> tuple[Path | None, Path | None]: for cjs in zp.discover(): res = cjs.parent.parent if (res / "app.asar").is_file(): - exe = res.parent / "ZCode.exe" - return res, (exe if exe.is_file() else None) + return res, _find_exe(res) except Exception as e: log(f"安装探测失败: {type(e).__name__}: {e}") return None, None @@ -143,15 +158,25 @@ def main() -> int: res, exe = resolve_install() if exe is None: - log("未找到 ZCode.exe(可用主脚本探测确认安装位置),请手动启动") + log("未找到 ZCode 主程序(Windows: ZCode.exe / macOS: .app 包内 MacOS/ZCode),请手动启动") return 1 log(f"处理完成(失败 {failed} 项),重启 ZCode") if zcode_running(): log("检测到 ZCode 已再次运行,跳过重启") return 0 try: - subprocess.Popen([str(exe)], cwd=str(exe.parent), - creationflags=0x00000008) # DETACHED_PROCESS + if os.name == "nt": + subprocess.Popen([str(exe)], cwd=str(exe.parent), + **no_window_kwargs()) # 防弹控制台窗口(上游单测要求) + elif sys.platform == "darwin" and res is not None and res.parent.parent.suffix == ".app": + # macOS 经 Launch Services 打开 .app 包。注意 creationflags 是 Windows 专属 + # 参数,POSIX 上传入会直接抛 ValueError,所以必须按平台分流。 + subprocess.Popen(["open", str(res.parent.parent)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + **no_window_kwargs()) + else: + subprocess.Popen([str(exe)], cwd=str(exe.parent), start_new_session=True, + **no_window_kwargs()) except Exception as e: log(f"重启 ZCode 失败(请手动启动): {e}") return 1 @@ -160,4 +185,11 @@ def main() -> int: if __name__ == "__main__": - sys.exit(main()) + try: + sys.exit(main()) + except Exception: + # 看护无人值守:崩溃必须留痕。此前 stderr 被 start_watchdog 定向到 DEVNULL, + # 崩溃无声 —— 这正是 macOS 上「等退出 0 次」长期没被发现的原因。 + import traceback + log("看护异常退出:\n" + traceback.format_exc()) + sys.exit(1) diff --git a/skills/zcode-tokenspeed/scripts/sync.py b/skills/zcode-tokenspeed/scripts/sync.py index f572651..6621017 100644 --- a/skills/zcode-tokenspeed/scripts/sync.py +++ b/skills/zcode-tokenspeed/scripts/sync.py @@ -377,10 +377,14 @@ def run_patcher(args, revert: bool) -> str: def start_watchdog(wanted: dict) -> None: """启动退出后看护:等 ZCode 退出 → 应用重打包级补丁。""" args = [f"--want={k}={'on' if v else 'off'}" for k, v in wanted.items()] - flags = (DETACHED_PROCESS | CREATE_NO_WINDOW) if os.name == "nt" else 0 - subprocess.Popen([sys.executable, str(WATCHDOG), *args], cwd=str(HERE), - creationflags=flags, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL) + kw = {"cwd": str(HERE), "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL} + if os.name == "nt": + kw["creationflags"] = DETACHED_PROCESS | CREATE_NO_WINDOW + else: + # POSIX:自成会话组(与 spawn_detached 同一做法),否则看护留在钩子的 + # 进程组里,应用退出/清理时可能被整组信号连带杀掉。 + kw["start_new_session"] = True + subprocess.Popen([sys.executable, str(WATCHDOG), *args], **kw) # no-window-ok: kw 已按平台携带 DETACHED_PROCESS|CREATE_NO_WINDOW(Windows)/ start_new_session(POSIX) log(f"已启动退出后看护: {' '.join(args)}") From 8173cbda8f6d18ec38c47a0c1d143291769dd476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=B1=E5=9B=BD=E7=91=9E?= Date: Thu, 24 Sep 2026 15:47:20 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(macos):=20asar=20=E5=86=99=E5=85=A5?= =?UTF-8?q?=E9=94=81=20POSIX=20=E7=94=A8=20fcntl=EF=BC=9B=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E6=8E=A2=E9=92=88=E7=94=A8=20PureWindowsPath?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _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) 转回 --- .../zcode-tokenspeed/scripts/zcode_patcher.py | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/skills/zcode-tokenspeed/scripts/zcode_patcher.py b/skills/zcode-tokenspeed/scripts/zcode_patcher.py index 7d117a3..4e8436c 100644 --- a/skills/zcode-tokenspeed/scripts/zcode_patcher.py +++ b/skills/zcode-tokenspeed/scripts/zcode_patcher.py @@ -121,7 +121,7 @@ import sys import threading import time -from pathlib import Path +from pathlib import Path, PureWindowsPath try: # 控制台编码/窗口安全网(见 _console.py 的说明) from _console import bad_mark, no_window_kwargs, ok_mark, safe_stdio @@ -457,9 +457,13 @@ def _from_running_processes(found: list[Path]) -> None: out = out.decode(errors="replace") for line in out.splitlines(): line = line.strip() - # ZCode.exe / ZCode Skin Manager 等都指向安装根目录 - if line.lower().endswith(".exe") and "zcode" in _norm(Path(line).name): - found.append(Path(line).parent) + # ZCode.exe / ZCode Skin Manager 等都指向安装根目录。 + # 存 PureWindowsPath 而非 Path:单测把 os.name 伪造成 "nt"(zp.os 即全局 os + # 模块单例),此时 Path() 在 macOS/Linux 上会尝试构造 WindowsPath 并抛 + # NotImplementedError;discover() 消费时统一 Path(root) 转回具体路径 + # (真实 Windows 运行时才转换,PureWindowsPath 属性与 WindowsPath 一致)。 + if line.lower().endswith(".exe") and "zcode" in _norm(PureWindowsPath(line).name): + found.append(PureWindowsPath(line).parent) def _from_registry(found: list[Path]) -> None: @@ -541,7 +545,7 @@ def discover() -> list[Path]: if VERBOSE: print(f"[·] 探测器 {probe.__name__} 命中 {len(roots) - before} 个候选目录") seen, result = set(), [] - for root in roots: + for root in map(Path, roots): # 探针可能存 PureWindowsPath(见 _from_running_processes) cjs = root / "resources" / "glm" / "zcode.cjs" try: key = cjs.resolve() @@ -1762,16 +1766,22 @@ def __enter__(self): raise TimeoutError( f"等待 asar 写入锁超时({self.timeout:.0f}s):{self.path.name}\n" f" 本进程内已有注入流程正在写入,请稍后重试") - # ② 再拿跨进程文件锁 + # ② 再拿跨进程文件锁(Windows msvcrt / POSIX fcntl —— 插件在 macOS/Linux 也会注入客户端) try: - import msvcrt # Windows 专用;本工具只在 Windows 注入客户端 + if os.name == "nt": + import msvcrt # Windows 专用 + else: + import fcntl # POSIX 跨进程文件锁 deadline = time.time() + self.timeout self.path.parent.mkdir(parents=True, exist_ok=True) while True: try: fh = open(self.path, "a+b") try: - msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1) + if os.name == "nt": + msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1) + else: + fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: fh.close() raise @@ -1805,9 +1815,13 @@ def _release_thread(self): def __exit__(self, *exc): if self._fh is not None: try: - import msvcrt self._fh.seek(0) - msvcrt.locking(self._fh.fileno(), msvcrt.LK_UNLCK, 1) + if os.name == "nt": + import msvcrt + msvcrt.locking(self._fh.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN) except OSError: pass try: From 54925390e357afeb3fd66920704d839fc228e028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=B1=E5=9B=BD=E7=91=9E?= Date: Thu, 24 Sep 2026 15:47:20 +0800 Subject: [PATCH 3/6] =?UTF-8?q?test:=20stale=20=E5=A4=87=E4=BB=BD=20glob?= =?UTF-8?q?=20=E6=8E=92=E9=99=A4=20.meta.json=EF=BC=88=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20flaky=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _archive_backup 把 bak 与 bak.meta.json 一起改名成 stale-*,两者都命中 glob 模式;目录枚举顺序随时间戳哈希变化,stale[0] 取到谁是随机的—— 实测同代码 10 跑 4~6 挂。排除 .meta.json 后 20 连跑全绿。 --- tests/test_patcher.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_patcher.py b/tests/test_patcher.py index 512b7a3..182c238 100644 --- a/tests/test_patcher.py +++ b/tests/test_patcher.py @@ -567,7 +567,11 @@ def test_patch_after_upgrade_archives_stale_backup(self): upgraded = make_cjs(zp.ANCHORS["3.9.2"], prefix="/*upgraded-kernel*/") self.cjs.write_bytes(upgraded) self.assertTrue(self.patch()) - stale = list(self.tmp.glob("zcode.cjs.bak.stale-*")) + # glob 必须排除 .meta.json:_archive_backup 会把 bak 与 bak.meta.json 一起改名成 + # stale-*,两者都命中该模式;目录枚举顺序(APFS 按名字哈希)随时间戳变化, + # stale[0] 取到谁是随机的——曾表现为同代码 10 跑 4~6 挂的 flaky。 + stale = [p for p in self.tmp.glob("zcode.cjs.bak.stale-*") + if not p.name.endswith(".meta.json")] self.assertTrue(stale, "旧备份未归档") meta = json.loads((self.tmp / "zcode.cjs.bak.meta.json").read_text(encoding="utf-8")) self.assertEqual(meta["sha256"], zp._sha256(upgraded), "备份不是升级后内核的原始副本") From 098f2ff1c157f0fa69bdae9f385100199ff96dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=B1=E5=9B=BD=E7=91=9E?= Date: Thu, 24 Sep 2026 15:49:58 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(macos):=20zcode=5Frunning=20=E6=8E=92?= =?UTF-8?q?=E9=99=A4=E6=B3=84=E6=BC=8F=E7=9A=84=20crashpad=20=E5=83=B5?= =?UTF-8?q?=E5=B0=B8=EF=BC=8C=E7=9C=8B=E6=8A=A4=E4=B8=8D=E5=86=8D=E6=B0=B8?= =?UTF-8?q?=E8=BF=9C=E7=AD=89=E4=B8=8D=E5=88=B0=E9=80=80=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- .../zcode-tokenspeed/scripts/zcode_patcher.py | 36 ++++++++- tests/test_patcher.py | 81 +++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/skills/zcode-tokenspeed/scripts/zcode_patcher.py b/skills/zcode-tokenspeed/scripts/zcode_patcher.py index 4e8436c..d4e02a0 100644 --- a/skills/zcode-tokenspeed/scripts/zcode_patcher.py +++ b/skills/zcode-tokenspeed/scripts/zcode_patcher.py @@ -2723,6 +2723,37 @@ def _resolve_asars(target: str | None) -> list[Path]: return asars +def _pid_commandline(pid: str) -> "str | None": + """POSIX:返回进程完整命令行;进程已消失/查询失败返回 None。""" + try: + r = subprocess.run(["ps", "-o", "command=", "-p", pid], + capture_output=True, timeout=5, **no_window_kwargs()) + if r.returncode != 0: + return None + return r.stdout.decode(errors="replace").strip() + except Exception: + return None + + +def _pgrep_hits_are_alive(pids: "list[str]", cmdline_of=None) -> bool: + """对 pgrep -f ZCode 命中的 PID 逐个核对命令行,排除 crashpad 后判应用是否存活。 + + ★ 为什么必须逐个核对(2026-09-24 macOS 实测):ZCode 每次退出都会泄漏一个 + chrome_crashpad_handler(ppid=1 的崩溃报告进程,命令行仍含 ZCode.app 路径), + pgrep -f ZCode 因此永远非零——退出后看护等不到「退出」,重打包级补丁永远写不 + 进去。本机曾堆积 3.9.1 / 3.10.1 时代的多个泄漏进程,应用本体早已不在。应用真正 + 存活时主进程 / ZCode Helper 必然在列且非 crashpad,不受此排除影响。 + cmdline_of 返回 None(进程在 pgrep 与 ps 之间消失)按已退出处理。 + """ + cmdline_of = cmdline_of or _pid_commandline + for pid in pids: + cmd = cmdline_of(pid) + if cmd is None or "chrome_crashpad_handler" in cmd: + continue # 已消失的 / crashpad 僵尸:不代表应用存活 + return True + return False + + def zcode_running() -> bool: """ZCode 是否在运行(打补丁前预检:运行中会锁住 app.asar,配置也可能被回写覆盖)。""" try: @@ -2732,7 +2763,10 @@ def zcode_running() -> bool: **no_window_kwargs()).stdout or b"" return b"ZCode.exe" in out r = subprocess.run(["pgrep", "-f", "ZCode"], capture_output=True, timeout=10) # no-window-ok: 只在 POSIX 分支执行 - return r.returncode == 0 + if r.returncode != 0: + return False + pids = r.stdout.decode(errors="replace").split() + return _pgrep_hits_are_alive(pids) except Exception: return False diff --git a/tests/test_patcher.py b/tests/test_patcher.py index 182c238..85db122 100644 --- a/tests/test_patcher.py +++ b/tests/test_patcher.py @@ -1174,6 +1174,87 @@ def test_non_zcode_paths_are_ignored(self): self.assertEqual(found, []) +@unittest.skipIf(os.name == "nt", "POSIX 分支(Windows 走 tasklist,不经过这段)") +class TestZcodeRunningPosix(unittest.TestCase): + """zcode_running 的 POSIX 分支:泄漏的 crashpad 僵尸必须被排除。 + + macOS 实测(2026-09-24):ZCode 每次退出都泄漏一个 chrome_crashpad_handler + (ppid=1、命令行含 ZCode.app 路径),pgrep -f ZCode 永远非零,退出后看护 + 等不到「退出」、重打包级补丁永远写不进去——曾堆积 3.9.1/3.10.1 时代的进程。 + """ + + CRASHPAD = ("/Applications/ZCode.app/Contents/Frameworks/Electron Framework.framework" + "/Helpers/chrome_crashpad_handler --annotation=_productName=ZCode") + HELPER = ("/Applications/ZCode.app/Contents/Frameworks/ZCode Helper.app/Contents" + "/MacOS/ZCode Helper --type=gpu-process") + + def setUp(self): + import zcode_patcher as zp + self.zp = zp + self._orig_run = zp.subprocess.run + + def tearDown(self): + self.zp.subprocess.run = self._orig_run + + def _install_fake(self, pgrep_rc, pgrep_out, cmdlines): + """cmdlines: pid(str) -> 命令行字符串;None 表示 ps 查不到(进程已消失)。""" + class _R: + def __init__(self, rc, out): + self.returncode = rc + self.stdout = out + + def fake_run(args, **kw): + if args[0] == "pgrep": + return _R(pgrep_rc, pgrep_out) + cmd = cmdlines.get(args[-1], "") + return _R(0 if cmd is not None else 1, (cmd or "").encode()) + + self.zp.subprocess.run = fake_run + + def test_no_hits_means_not_running(self): + self._install_fake(1, b"", {}) + self.assertFalse(self.zp.zcode_running()) + + def test_only_crashpad_zombies_means_not_running(self): + """应用已退出、只剩泄漏的 crashpad(跨版本堆积)——必须判「未运行」。""" + self._install_fake(0, b"111 222 333\n", { + "111": self.CRASHPAD + " --version=3.14.3", + "222": self.CRASHPAD + " --version=3.10.1", + "333": self.CRASHPAD + " --version=3.9.1", + }) + self.assertFalse(self.zp.zcode_running()) + + def test_crashpad_plus_real_helper_means_running(self): + """crashpad 泄漏与真实 helper 并存——必须判「在运行」(crashpad 不影响判定)。""" + self._install_fake(0, b"111 222\n", {"111": self.HELPER, "222": self.CRASHPAD}) + self.assertTrue(self.zp.zcode_running()) + + def test_vanished_pid_counts_as_gone(self): + """pgrep 命中后进程在 ps 之前消失:按已退出处理,不得抛错或误判存活。""" + self._install_fake(0, b"111\n", {"111": None}) + self.assertFalse(self.zp.zcode_running()) + + def test_pid_commandline_reads_real_process(self): + """_pid_commandline 对真实子进程可用(ps 集成,非 mock)。""" + p = subprocess.Popen(["sleep", "5"]) + try: + cmd = self.zp._pid_commandline(str(p.pid)) + self.assertIsNotNone(cmd) + self.assertIn("sleep", cmd) + finally: + p.terminate() + p.wait() + + def test_hits_are_alive_with_injected_cmdline(self): + """纯函数入口:真实 cmdline_of 回调 + crashpad/真进程混合。""" + alive = self.zp._pgrep_hits_are_alive( + ["1", "2"], cmdline_of=lambda pid: self.CRASHPAD if pid == "1" else self.HELPER) + self.assertTrue(alive) + gone = self.zp._pgrep_hits_are_alive( + ["1"], cmdline_of=lambda pid: self.CRASHPAD) + self.assertFalse(gone) + + class TestNoConsoleWindowFlags(unittest.TestCase): """每个会起 console 子进程的调用都必须带「别弹控制台窗口」的标志。 From b21dead5f30aa90c49df3a8e71804bf9b79b84a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=B1=E5=9B=BD=E7=91=9E?= Date: Thu, 24 Sep 2026 16:38:14 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=5Fimport=5Fpatcher=20=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=20sys.path=20=E6=8F=92=E5=85=A5=EF=BC=88review=20?= =?UTF-8?q?=E6=84=8F=E8=A7=81=EF=BC=9A=E8=BD=AE=E8=AF=A2=E5=BE=AA=E7=8E=AF?= =?UTF-8?q?=E5=86=85=E6=97=A0=E7=95=8C=E5=A2=9E=E9=95=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 看护轮询每 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,故原问题主要是内存/整洁性而非查找性能——但仍应修。 --- .../scripts/apply_after_exit.py | 19 ++++++++++++++----- tests/test_patcher.py | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/skills/zcode-tokenspeed/scripts/apply_after_exit.py b/skills/zcode-tokenspeed/scripts/apply_after_exit.py index 545ade1..1ee0d77 100644 --- a/skills/zcode-tokenspeed/scripts/apply_after_exit.py +++ b/skills/zcode-tokenspeed/scripts/apply_after_exit.py @@ -37,6 +37,18 @@ PATCH_TIMEOUT = 600 +def _import_patcher(): + """导入主脚本模块;sys.path 只在缺失时插入。 + + 看护轮询循环每 POLL_SEC 秒调一次 zcode_running() → 本函数被反复执行, + 不能每次都 insert(sys.path 会无界增长——24h 上限约 2.9 万个重复项)。 + """ + if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + import zcode_patcher as zp + return zp + + def log(msg: str) -> None: with open(LOG, "a", encoding="utf-8") as f: f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n") @@ -51,9 +63,7 @@ def zcode_running() -> bool: # 轮询就崩溃;启动方又把 stderr 定向到 DEVNULL,崩溃完全无声,表现为 # 「等退出 0 次、完成 0 次」、补丁永远写不进去。 try: - sys.path.insert(0, str(HERE)) - import zcode_patcher as zp - return zp.zcode_running() + return _import_patcher().zcode_running() except Exception as e: log(f"运行检测失败: {type(e).__name__}: {e},本轮按「仍在运行」处理") return True @@ -76,8 +86,7 @@ def _find_exe(res: Path) -> Path | None: def resolve_install() -> tuple[Path | None, Path | None]: """复用主脚本的跨平台探测拿到 (asar 目录, ZCode 可执行文件)。""" try: - sys.path.insert(0, str(HERE)) - import zcode_patcher as zp + zp = _import_patcher() for cjs in zp.discover(): res = cjs.parent.parent if (res / "app.asar").is_file(): diff --git a/tests/test_patcher.py b/tests/test_patcher.py index 85db122..0953fb4 100644 --- a/tests/test_patcher.py +++ b/tests/test_patcher.py @@ -1255,6 +1255,23 @@ def test_hits_are_alive_with_injected_cmdline(self): self.assertFalse(gone) +class TestApplyAfterExitPathGuard(unittest.TestCase): + """apply_after_exit 的看护轮询每 3s 调一次 zcode_running()(最长 24h)。 + + 曾经的写法是每次 sys.path.insert(0, HERE)——列表无界增长(24h 约 2.9 万个 + 重复项)。_import_patcher 必须只在缺失时插入;同时不得破坏 import 缓存。 + """ + + def test_repeated_imports_do_not_grow_sys_path(self): + import apply_after_exit as aae + zp = aae._import_patcher() + self.assertIs(zp, sys.modules["zcode_patcher"], "应当复用缓存的模块对象") + baseline = len(sys.path) + for _ in range(200): # 模拟 10 分钟轮询量级的调用 + aae._import_patcher() + self.assertEqual(len(sys.path), baseline, "sys.path 无界增长") + + class TestNoConsoleWindowFlags(unittest.TestCase): """每个会起 console 子进程的调用都必须带「别弹控制台窗口」的标志。 From 3addc75a903d3b5febbf67987ee0bc5f56394b89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=B1=E5=9B=BD=E7=91=9E?= Date: Thu, 24 Sep 2026 16:51:17 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20Windows=20=E9=87=8D=E5=90=AF?= =?UTF-8?q?=E5=88=86=E6=94=AF=E6=81=A2=E5=A4=8D=E4=B8=8A=E6=B8=B8=E5=8E=9F?= =?UTF-8?q?=E6=A0=B7=20creationflags=3DDETACHED=5FPROCESS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自查发现:上一提交为过 no-window 静态检查把它改成 **no_window_kwargs() (CREATE_NO_WINDOW),与上游的 DETACHED_PROCESS 不同——违背本 PR 「Windows 行为零变化」的承诺。静态检查本来就接受 creationflags 字面量, 无需改值。GUI 程序下两者效果几乎一致,但保持逐字节同语义更诚实。 --- skills/zcode-tokenspeed/scripts/apply_after_exit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/zcode-tokenspeed/scripts/apply_after_exit.py b/skills/zcode-tokenspeed/scripts/apply_after_exit.py index 1ee0d77..9b0c801 100644 --- a/skills/zcode-tokenspeed/scripts/apply_after_exit.py +++ b/skills/zcode-tokenspeed/scripts/apply_after_exit.py @@ -176,7 +176,7 @@ def main() -> int: try: if os.name == "nt": subprocess.Popen([str(exe)], cwd=str(exe.parent), - **no_window_kwargs()) # 防弹控制台窗口(上游单测要求) + creationflags=0x00000008) # DETACHED_PROCESS——保持上游原样,Windows 行为零变化 elif sys.platform == "darwin" and res is not None and res.parent.parent.suffix == ".app": # macOS 经 Launch Services 打开 .app 包。注意 creationflags 是 Windows 专属 # 参数,POSIX 上传入会直接抛 ValueError,所以必须按平台分流。