Skip to content

Releases: LanJinGod/dsh-purge

dsh-purge 1.1.73+dsh0.2.1 — 撞上重启标记也先撤锁

Choose a tag to compare

@LanJinGod LanJinGod released this 18 Oct 15:58

中文

  • 版本 1.1.73。主题:排队了就得真在等 —— 撞上「官方重启中」也得先撤锁。
    本版只有一处行为修正,但它正是「状态页说在等、其实没人在等」的那条路。

一、空闲接管撞上「官方重启中」标记时,退出前撤锁

  • 现场(2026-10-18 23:10):boot 对账排上队 → 脚本第一轮就发现自己刚被官方重启标记挡下 → exit 0 走人。
    退出路径上没有 Clear-Lock,于是 app.asar.rename-pending 和 .pid 留在原地,
    账本却还是 idle-armed + 一个已经死掉的 pid(6316)——状态页照旧显示「在等: watcher:6316」。
  • 修法:lib/desktop.js 里 idle 脚本的该分支改为 Clear-Lock → Write-State 'standby' 'official-restarting' → exit 0。
    账本从此是 standby,状态页说「撞上官方重启,等下一轮」,不再假装有人守着。
  • 排队本身仍可靠:idleWatcherState(lock) 一直是「锁 + pid 活体 + 心跳」三件套,死 pid 当场判死锁并重排;
    本机实测(23:56)dsh-purge --swap 把 23:10 那个死锁清掉并重排成功,watcher pid 16792 心跳正常。

二、smoke:idle 补两条断言,把这类回归钉住

  • 第 2 步不再拿第 1 步的 watcher 演「关掉 App」:先按 base 路径清掉残留 watcher,再单独排一个
    「排上队时就没有 App 在跑」的脚本,断言它自己挪包、写 app.asar.bak、撤锁、落 relaunch。
    之前那版会两个 watcher 抢同一个 asar,谁最后写账本谁说了算,于是断言时绿时红(23:56 就红过一次)。
  • 新增第 4 步:造一个新鲜的 %TEMP%\dsh-purge-official-restarting 标记再排队,断言脚本走 standby、
    锁与 pid 都被撤掉、app.asar 没被动过 —— 本版修的那条路从此有测试盯着。

dsh-purge 1.1.72+dsh0.2.1 — 换包生效

Choose a tag to compare

@LanJinGod LanJinGod released this 18 Oct 15:49

1.1.72+dsh0.2.1

中文

  • 版本 1.1.72。主题:换包真的会跑起来;「写完」和「生效」分开算。
    上一版(1.1.71)最难受的地方不是补丁洗不干净,而是失败了没有任何痕迹——这两件事在本版一并修掉。

一、换包脚本根本没起来:修根因,并留下「起没起来」的证据

  • 真根因(实测,本机 Win11 + Electron 宿主):
    • spawn("powershell.exe", …, { detached: true, stdio: "ignore" }) → 子进程 33 ms 内以 0 退出,脚本一行都没执行;
    • spawn(…, { detached: false }) 再让父进程退出 → 子进程被宿主 Job 一起收走;
    • spawn("cmd.exe", ["/c", launcher.cmd], { detached: true }) → 活着跑完(cmd 无控制台也能正常启动);
    • Start-Process powershell / wscript 拉 .cmd 启动器 → 起不来。
    • 结果:账本永远停在 requested,没有 %TEMP%\dsh-purge-asar-*.log,app.asar 原地不动,
      而补丁早就写进了 resources/app —— 就是「洗完了但没生效」。
  • 新增 launchDetached(program, args, tag, env):Windows 一律写 %TEMP%\dsh-purge-<tag>-<pid>-<rand>.cmd 启动器,
    再用 cmd.exe /c 拉起;非 Windows 仍走 detached spawn。cmdQuote() 负责带空格/中文/& 的路径引号。
    换包 PS1、空闲接管 PS1、spawnHelper(helper 重启)、web.js 的重启 helper 全部改走它。
  • 新增 waitForSwapScript(files, timeoutMs):拉起脚本后先同步等它真的落第一行日志(默认 4 s,Atomics.wait 不烧 CPU)。
    等不到就不退出、不杀 App:账本写 failed / swap-script-not-started + 启动器路径,宿主原样继续跑。
    这样「换包没开始」和「换包开始了但改名失败」在状态页里是两件不同的事。
  • 空闲接管:脚本没起来时撤回 app.asar.rename-pending 锁,账本写 failed / idle-script-not-started,
    不会进入「30 分钟内都被当成已排队」的假挂起。
  • restartOfficialHarness() / scheduleApplyRestart() 的 restarting 改为以脚本真的起来为准
    (planned.restarting || planned.scheduled),不再靠 asarSwap 推断,界面不会谎报「正在重启」。
  • armSealedIdleTakeover() 返回体新增 reason / logPath,起不来时能说出原因。
  • 新增 scripts/smoke-swap.mjs(npm run smoke:swap):在临时目录搭假安装(假 app 树 + 假 asar + 假 exe),
    走生产代码 scheduleAsarSwapRestart → launchDetached → 换包 PS1 全链路,断言 start → killed → renamed → relaunch
    真的走完、.bak 已生成、日志有 4 行。本机实测 1.9 s 跑完 PASS——这条链路以前一次都没真正跑通过。
    脚本用 process.noAsar = true(Electron-as-node 下 fs 被 asar 包装)且 DSH_HOME 指向沙盒,不动真安装。
  • 清锁函数自己递归自己(已修):Clear-Lock 的函数体里被整体替换成调用 Clear-Lock,
    于是「改名成功 → 清锁」这一步直接递归爆栈,进程在改名之后、写账本之前就死了:
    app.asar 被挪走、.bak 也在,账本却还停在 idle-armed,锁永远不清。
    现在函数体用 [System.IO.File]::Delete 删除锁与 pid 文件,并留了注释说明这段为什么长这样。
  • 死锁不再被当成「已排队」:原来只看锁文件 mtime(< 30 分钟即视作已排队),
    22:56 那次死掉的脚本留下一个锁,之后每次 --swap 都输出「✓ 已排队(此前已排队)」而现场根本没有 watcher。
    现在 idleWatcherState() 会读 *.pid、核对进程是否活着、看心跳时间;pid 死了 → 判死锁 → 清锁重排,
    --swap / 状态页 / boot 对账都走这一条判定。
  • 长命脚本改放持久目录:排队可能隔几小时才落地,原来 .ps1 / .cmd 都写在 %TEMP%,临时目录被清掉就白排。
    新增 durableScratchDir()(~/.dsh/dsh-purge/swap,失败退 %TEMP%/dsh-purge-swap),
    空闲接管/换包的脚本与启动器都写在这里。
  • --apply 现在也会给桌面宿主打应用戳:全量都是「已是最新」时原来不写 applied.json,
    版本号会一直停在旧版本(这次就停在 1.1.71)。现在收尾统一 markPatchesApplied()。
  • --apply / --revert 的 shim 目标改成 desktop_shim || shim_dir:桌面宿主的 shim 在
    resources/runtime/cli/bin,原来只看 npm 全局目录,于是每次都打印「未定位 shim 目录」而静默跳过;
    桌面 .cmd 仍按设计不注入(会闪控制台),但这条现在会说清楚,不再报成 ⚠ 异常。
  • 新增 scripts/smoke-idle.mjs:空闲接管的三件事——排得上(锁+pid+活体校验)、
    活着的 watcher 在时返回 already 不重复起脚本、死锁会被识别并重排——本机 PASS。

二、状态与证据:状态页不再说谎

  • 版本 1.1.72。主题:「洗完了」和「生效了」分开算,换包失败要留证据。
  • 新增 lib/patch-effect.js:判定当前进程到底从哪一份代码加载。
    • classifyRuntime():命令行里出现 app.asar → sealed,出现 resources/app/ → unpacked;纯函数,可单测。
    • runtimeSource():进度内 process.argv / execArgv / resourcesPath 优先;判不出来才做一次只读的进程外探测
      (Windows 走 WMI 只读命令行,其他平台 ps),10 s 缓存,DSH_PURGE_NO_PS_PROBE=1 可关。
  • 状态页(purge_status / dsh-purge --status)新增三行:
    • 运行时加载 / runtime:app.asar(密封) 还是 resources/app(已接管),附判定依据。
    • 补丁生效 / effective:已生效/已写入 两个数分开。归档还在时明确写 0/N 已生效 — 已写入 …,但当前进程从 app.asar 加载(未生效)。
    • 换包 / swap:读换包账本,显示阶段、时间、失败原因、日志路径。
  • 换包账本 ~/.dsh/dsh-purge/asar-swap.json:node 侧与 PowerShell / sh 脚本都往里写阶段
    (requested → start → killed → renamed → relaunch / failed / aborted-reappeared / tree-incomplete / timeout)。
    每次换包另有 %TEMP%\dsh-purge-asar-*.log 全量日志——之前 stdio: "ignore",失败一点痕迹都没有。
  • 换包脚本顺序修正:先验 resources/app(package.json + main 入口 + dsh/)再杀进程;
    原生模块(app.asar.unpacked → napi-v9.node)也改成杀进程前补进去,补不进去一样不换。
    目录不完整直接 exit 2 写 tree-incomplete,桌面保持可用;不会出现「进程被杀、新目录又起不来」。
  • 换包后校验:Move-Item 成功但 app.asar 又出现(官方更新器同时在写)→ aborted-reappeared,不重开;已有同名进程 → relaunch-skipped-running,避免双开。
  • 启动期对账 core.reconcileSealedSwap():补丁写过(applied.json 在)、归档还在、这次却仍从 app.asar 加载,
    就自动排队一次空闲接管(不杀进程,用户下次关 dsh 时换包并重开)。这正是 1.1.71 里最难受的静默失败:
    apply 报成功、补丁在 resources/app 里,进程照旧读归档。
  • purge_apply / /purge apply / UI apply:密封包且这次没重启时自动排队空闲接管;返回体新增
    runtime_source / runtime_reason / effective / swap / swap_text / asar_swap_armed。
  • CLI 新增 --swap(排队空闲接管)与 --swap-now(立刻结束、换包、重开),--status 打印运行时加载源、生效条数、换包账本。
  • shim 状态语义修正:读失败(被占用 / 权限)不再报 binary,新增 unreadable;桌面端「shim 目录」显示真实的
    resources/runtime/cli/bin,不再显示「未定位」。
  • 顺带修掉一个真 bug:hostFs(按路径在 node:fs 与 original-fs 间路由的那层)没有 readSync / closeSync,
    于是 isTextShim() 每次都抛 TypeError 被 catch 成「不是文本」——dsh.cmd 多年一直显示 binary,
    备份占位 fd 也一直没关(Windows 上会挡住后续改名/删除)。现在补上这三个 fd 方法。
  • 新增 scripts/selftest.mjs + npm run selftest:9 组断言覆盖命令行判定、账本合并/BOM、目录体检、
    换包脚本阶段与「先验后杀」顺序、PowerShell 解析、状态渲染(密封/已接管)。

English

  • Version 1.1.72. Theme: the swap actually runs now, and "written" is no longer reported as "in effect" — the same release that stopped hiding failure.
  • Root cause (measured on this machine, Win11 + Electron host): a detached powershell.exe child exits with code 0
    in ~33 ms without executing a single line; the non-detached variant is reaped with the host's job object;
    cmd.exe /c launcher.cmd detached does survive. Start-Process powershell and wscript-driven .cmd launchers do not.
  • New launchDetached() writes a .cmd launcher under %TEMP% and starts it through cmd.exe /c on Windows
    (detached spawn elsewhere), with cmdQuote() for paths containing spaces, CJK or &. The swap PS1, the idle-swap PS1,
    spawnHelper() and the web restart helper all route through it.
  • New waitForSwapScript() waits synchronously (default 4 s, Atomics.wait) for the script to write its first log line.
    If it never does, nothing exits and no App is killed: the ledger records failed / swap-script-not-started
    with the launcher path, and the host keeps running.
  • restarting now reflects "the script really started" (planned.restarting || planned.scheduled) instead of being
    inferred from asarSwap, so the status page stops claiming a restart that is not happening.
  • New scripts/smoke-swap.mjs (npm run smoke:swap) builds a throwaway install (fake app tree, fake asar, fake exe)
    and drives the production path end to end, asserting start → killed → renamed → relaunch, a .bak, and 4 log lines.
    Measured here: PASS in ~1.9 s.
  • The lock-clearing function recursed into itself (fixed): its body had been rewritten to call Clear-Lock,
    so "rename succeeded → clear lock" blew the stack and the process died after the rename but before writing the ledger:
    app.asar was gone, .bak existed, and the ledger still said idle-armed with the lock left behind.
    The body now deletes the lock and pid file via [System.IO.File]::Delete.
  • A dead lock no longer counts as queued: the old check trusted the lock mtime (< 30 min = queued), so the lock left by
    that 22:56 crash made every later --swap print "already queued" with no watcher alive. idleWatcherState() now reads
    *.pid, verifies the process is alive and how stale its heartbeat is; a dead pid is treated as a dead lock, cleared and
    re-queued — and --swap, the status page and boot reconciliation all use that one verdict.
  • Long-lived scripts moved out of %TEMP%: a queue can land hours later, so temp cleanup used to be able to erase it.
    New durableScratchDir() (~/.dsh/dsh-purge/swap, falling back to %TEMP%/dsh-purge-swap) holds the idle/swap
    scripts and their launchers.
  • --apply now also stamps the desktop host: when every patch is already current nothing was written, so
    applied.json kept the old version (it was stuck at 1.1.72). The finish path now calls markPatchesApplied().
  • --apply / --revert pick desktop_shim || shim_dir for the shim target: the desktop shim lives in
    resources/runtime/cli/bin, and only consulting the npm global dir printed "shim dir not found" and skipped it
    silently. The desktop .cmd is still deliberately not injected (console flash), but that is now stated instead
    of being reported as an anomaly.
  • New scripts/smoke-idle.mjs covers the three idle-takeover properties: it queues (lock + pid + liveness), an alive
    watcher yields already without spawning a second script, and a dead lock is detected and re-queued. PASS locally.

Two: status tells the truth

  • Version 1.1.72. Theme: "patched" and "in effect" are two different things; a failed asar swap must leave evidence.
  • New lib/patch-effect.js decides which copy of the harness the running process actually loaded.
    • classifyRuntime(): app.asar in the command line → sealed, resources/app/ → unpacked; pure and testable.
    • runtimeSource(): in-process process.argv / execArgv / resourcesPath first; only when that is inconclusive does it
      run one read-only external probe (WMI command line on Windows, ps elsewhere), cached 10 s, disabled by DSH_PURGE_NO_PS_PROBE=1.
  • Status (purge_status / dsh-purge --status) gains three lines:
    • runtime: sealed vs unpacked plus the evidence.
    • effective: written vs actually loaded. While the archive is present it reports 0/N effective with the reason.
    • swap: phase, time, last error and log path from the swap ledger.
  • Swap ledger at ~/.dsh/dsh-purge/asar-swap.json, written by node and by both swap scripts
    (requested → start → killed → renamed → relaunch, failed, aborted-reappeared, tree-incomplete, timeout),
    plus a full %TEMP%\dsh-purge-asar-*.log per attempt. Previously stdio was ignored, so failures left no trace at all.
  • Swap scripts now verify resources/app (package.json + main entry + dsh/) before killing anything; the native
    module from app.asar.unpacked is overlaid before the kill too, an...
Read more