Repository navigation
Releases: LanJinGod/dsh-purge
Releases · LanJinGod/dsh-purge
Release list
dsh-purge 1.1.73+dsh0.2.1 — 撞上重启标记也先撤锁
中文
- 版本 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 — 换包生效
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.exechild 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.cmddetached does survive.Start-Process powershellandwscript-driven.cmdlaunchers do not. - New
launchDetached()writes a.cmdlauncher under%TEMP%and starts it throughcmd.exe /con Windows
(detached spawn elsewhere), withcmdQuote()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 recordsfailed / swap-script-not-started
with the launcher path, and the host keeps running. restartingnow reflects "the script really started" (planned.restarting || planned.scheduled) instead of being
inferred fromasarSwap, 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, assertingstart → 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.asarwas gone,.bakexisted, and the ledger still saididle-armedwith 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--swapprint "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.
NewdurableScratchDir()(~/.dsh/dsh-purge/swap, falling back to%TEMP%/dsh-purge-swap) holds the idle/swap
scripts and their launchers. --applynow also stamps the desktop host: when every patch is already current nothing was written, so
applied.jsonkept the old version (it was stuck at 1.1.72). The finish path now callsmarkPatchesApplied().--apply/--revertpickdesktop_shim || shim_dirfor 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.cmdis still deliberately not injected (console flash), but that is now stated instead
of being reported as an anomaly.- New
scripts/smoke-idle.mjscovers the three idle-takeover properties: it queues (lock + pid + liveness), an alive
watcher yieldsalreadywithout 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.jsdecides which copy of the harness the running process actually loaded.classifyRuntime():app.asarin the command line →sealed,resources/app/→unpacked; pure and testable.runtimeSource(): in-processprocess.argv/execArgv/resourcesPathfirst; only when that is inconclusive does it
run one read-only external probe (WMI command line on Windows,pselsewhere), cached 10 s, disabled byDSH_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 reports0/N effectivewith 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-*.logper attempt. Previously stdio was ignored, so failures left no trace at all. - Swap scripts now verify
resources/app(package.json +mainentry +dsh/) before killing anything; the native
module fromapp.asar.unpackedis overlaid before the kill too, an...