DSH / Atlas
2026-09-24implementedbug-fixarchived 2026-09-30

Taking over a writer lock whose holder exited

接管持有者已退出的写锁

`withFileLock` in [dsh-atomic-write](../../../../packages/util/atomic-write/README.md) creates `<file>.lock` with exclusive create and removes it in a `finally`. A process that ends without running that `finally` leaves the lock behind, and every later writer of the file times out until someone deletes it by hand. The [original decision](../../archived/architecture/2026-07-30-settings-write-path-integrity.md) accepte

English

Problem

withFileLock in dsh-atomic-write creates <file>.lock with exclusive create and removes it in a finally. A process that ends without running that finally leaves the lock behind, and every later writer of the file times out until someone deletes it by hand. The original decision accepted this because file age cannot distinguish a crashed holder from a paused one.

The profile package lock (<profile>/package.json.lock) made the cost visible. dsh plugin installs no signal handler, so Ctrl-C, SIGTERM, or closing its terminal ends the process while pnpm runs, and the lock stays. dsh and dsh web exit right after application disposal resolves, while a cancelled installation is still restoring files and has not released the lock. A crash, SIGKILL, or out-of-memory kill has the same effect. After any of these, every plugin operation on that profile fails after lockWaitMs (two minutes by default) without saying that the holder is gone.

Decision

  • The lock keeps the <pid>\n record earlier releases wrote, so locks left before this change are taken over and processes of both releases interoperate.
  • A contender that finds a lock reads its record. When a signal probe of the PID fails with ESRCH, the holder is proven gone and the lock is taken over. EPERM means the process exists under another user and the lock is kept. A record naming the contender's own process is kept too: that process is running, and the browser Worker's process shim reports its own PID as absent.
  • Contenders that read the same record serialize on a claim file, <file>.lock.takeover-<first 16 hex digits of the record's SHA-256>, created with wx. The claimant re-reads the lock and probes its PID again, removes it only if it still holds that record and the PID is still absent, removes the claim, and retries acquisition at once. The record's holder cannot release it once its PID is absent, another takeover needs the same claim, and a new holder that reused the PID fails the second probe, so a claimant never removes a lock that another contender acquired after the exited holder's.
  • A record that is empty, incomplete, not a decimal PID, or names PID 0 or a PID beyond the int32 range the probe accepts is waited for. None of these proves that the holder stopped.
  • Takeover proves only that the recorded process exited, not that the processes it started stopped writing. The Plugin Manager records each pnpm run in .plugin-manager/run.json while the run lasts; an operation that finds that record waits up to five seconds for the recorded run to stop and otherwise refuses to run, naming the process and the record.
  • The probe runs on the contender's host. Writers on several hosts sharing one DSH_HOME over a network filesystem are unsupported; the flock that guards Session files does not reliably exclude them either.

Alternatives considered

Remove a lock older than a fixed age. Age cannot separate a crashed holder from one running a ten-minute pnpm installation, and the plugin manager legitimately holds the lock that long.

Kernel-released locks. The kernel releases them when the holder dies, which removes the problem instead of detecting it. The Session write lease already holds one on both platforms: flock through @deepseek-ai/node-addon-system on POSIX and a named semaphore through koffi on Windows. Moving withFileLock onto it would give the zero-dependency dsh-atomic-write both dependencies, and processes of earlier releases still exclude each other only through <file>.lock, so a transition would have to hold both locks. This stays the stronger fix if PID reuse or shared-filesystem deployments turn out to matter.

Rename the stale lock aside and restore it when the renamed record differs. Restoring can race a third contender that acquires the empty path in between, which leaves two holders. The claim file prevents that race without restoring anything.

Record the hostname and a nonce. A hostname would keep a writer on another host from probing a PID that is not its own, but no supported deployment shares these files across hosts, and macOS changes its hostname when networks change, which would leave locks from before the change in place. A nonce would make each record unique, but the second probe under the claim already rejects a holder that reused the PID.

Terminate the recorded pnpm run instead of refusing. Its PID can have been reused by an unrelated process, which a signal would then kill. Refusing leaves that case to the operator, whom the diagnostic names the process and the record.

Signal handlers in dsh plugin only. This covers one of the paths that leave a lock and leaves crashes, forced exits, and the other lock files behind.

Testing

EvidenceBehaviour
atomic-write.spec.tsTakeover of an exited holder's record, through an injected probe everywhere and a really exited process on POSIX; eight contenders over one exited holder never overlap; live, own-process, other-user, empty, incomplete, non-PID, process-group, and out-of-range records are waited for; a holder that reused the PID after the claim, an unreadable lock, a claim held by another contender, and a record replaced after the claim are left in place; a lock that cannot be removed is waited for; an EPERM claim refusal is retried, another claim failure surfaces, and a claim that cannot be removed does not fail the operation.
operations.spec.tsCLI, service, and repair runs are recorded while they run and the record is removed afterwards; an active recorded run refuses the operation before pnpm starts; a stopped one's record is removed; a record that names no run refuses the operation.
operations-process.spec.tsA real recorded run that is still writing finishes before the next operation's pnpm starts.

Consequences

  • Locks left by an exited process, including those written before this change, no longer need an operator; the next writer proceeds without waiting for its deadline.
  • A PID that a live process reused after a reboot keeps the lock in place until an operator removes it, as before.
  • Processes that share the lock file but not a PID namespace, such as containers over one volume or hosts over a network filesystem, can see a live holder as exited. Such deployments are not supported.
  • A contender that crashes while it holds a claim leaves that claim, and the named lock is then not taken over automatically.
  • A pnpm run left by an exited operation blocks later operations until it stops or an operator removes its record.
  • A holder that is still alive keeps its lock, so this does not bound how long a live operation holds it.

中文

问题

dsh-atomic-write 的 withFileLock 以独占创建方式创建 <file>.lock,并在 finally 中移除它。进程若未执行该 finally 就结束,锁会留在原地,该文件后续的每个写入方都会超时,直到有人手动删除它。原始决策接受了这一点,因为文件存续时间无法区分已崩溃的持有者与暂停的持有者。

profile 包锁(<profile>/package.json.lock)让这个代价显现出来。dsh plugin 不安装信号处理器,因此在 pnpm 运行期间按 Ctrl-C、发送 SIGTERM 或关闭其终端都会结束进程,锁随之留下。dsh 与 dsh web 在应用销毁完成后立即退出,而此时被取消的安装仍在恢复文件,尚未释放锁。崩溃、SIGKILL 或内存不足被杀也有同样效果。发生上述任一情况后,该 profile 上的每个插件操作都会在 lockWaitMs(默认两分钟)后失败,且不说明持有者已不存在。

决策

  • 锁沿用早期版本写入的 <pid>\n 记录,因此本变更之前遗留的锁也会被接管,新旧版本的进程可以互操作。
  • 竞争者发现锁时读取其记录。若对该 PID 的信号探测以 ESRCH 失败,则证明持有者已不存在,锁会被接管。EPERM 表示该进程以其他用户身份存在,锁保留。指向竞争者自身进程的记录同样保留:该进程正在运行,而浏览器 Worker 的进程模拟层会把自身 PID 报告为不存在。
  • 读到同一条记录的竞争者通过一个以 wx 创建的认领文件 <file>.lock.takeover-<记录 SHA-256 的前 16 位十六进制> 串行化。认领者重新读取锁并再次探测其 PID,仅当锁仍是该记录且该 PID 仍不存在时才移除它,随后移除认领文件并立即重试获取。PID 不存在时记录的持有者无法再释放它,另一次接管需要同一个认领文件,复用了该 PID 的新持有者无法通过第二次探测,因此认领者绝不会移除另一个竞争者在已退出持有者之后获得的锁。
  • 记录为空、不完整、不是十进制 PID,或指向 PID 0 或超出探测所接受的 int32 范围的 PID 时,竞争者继续等待。这些情况都无法证明持有者已经停止。
  • 接管只能证明记录中的进程已退出,不能证明它启动的进程已停止写入。Plugin Manager 在每次 pnpm 运行期间把它记录在 .plugin-manager/run.json 中;发现该记录的操作最多等待五秒让记录中的运行停止,否则拒绝运行,并指明该进程与记录文件。
  • 探测在竞争者所在主机上进行。不支持多台主机通过网络文件系统共享同一个 DSH_HOME 写入;保护 Session 文件的 flock 同样无法可靠地排除它们。

考虑过的替代方案

移除超过固定时长的锁。 时长无法区分已崩溃的持有者与正在运行十分钟 pnpm 安装的持有者,而插件管理器会合理地持锁这么久。

由内核释放的锁。 持有者死亡时内核会释放它们,从根源上消除问题而不是检测它。Session 写租约已在两个平台上持有这种锁:POSIX 上通过 @deepseek-ai/node-addon-system 使用 flock,Windows 上通过 koffi 使用命名信号量。把 withFileLock 迁移到它上面,会让零依赖的 dsh-atomic-write 引入这两个依赖;而早期版本的进程之间只通过 <file>.lock 互斥,因此过渡期必须同时持有两种锁。若 PID 复用或共享文件系统部署变得重要,这仍是更强的修复。

把陈旧锁重命名到别处,若重命名后的记录不同再恢复。 恢复可能与在此间隙获取空路径的第三个竞争者竞态,导致出现两个持有者。认领文件无需任何恢复即可防止这一竞态。

记录主机名和随机数。 主机名能让其他主机上的写入方不去探测不属于自己的 PID,但没有受支持的部署会跨主机共享这些文件,而且 macOS 切换网络时会改变主机名,导致变更前留下的锁无法接管。随机数能让每条记录唯一,但认领后的第二次探测已经能排除复用了该 PID 的持有者。

终止记录中的 pnpm 运行而不是拒绝。 其 PID 可能已被无关进程复用,发送信号会杀死该进程。拒绝则把这种情况交给操作者,诊断会指明该进程与记录文件。

只在 dsh plugin 中安装信号处理器。 这只覆盖留下锁的其中一条路径,崩溃、强制退出和其他锁文件仍会遗留。

验证

证据行为
atomic-write.spec.ts接管已退出持有者的记录,各平台通过注入的探测验证,POSIX 上另用真实已退出的进程验证;八个竞争者同时接管一个已退出持有者时从不重叠;存活、自身进程、其他用户、空、不完整、非 PID、进程组和超出范围的记录均继续等待;认领后复用了该 PID 的持有者、无法读取的锁、被另一竞争者持有的认领、认领后被替换的记录都保留原样;无法移除的锁会继续等待;以 EPERM 拒绝的认领会重试,其他认领失败会抛出,认领无法移除时操作不失败。
operations.spec.tsCLI、service 与修复运行在运行期间被记录,结束后删除记录;仍活动的记录中运行会在 pnpm 启动前拒绝操作;已停止运行的记录被删除;不指向任何运行的记录会拒绝操作。
operations-process.spec.ts仍在写入的真实记录中运行会在下一个操作的 pnpm 启动前结束。

后果

  • 已退出进程留下的锁(包括本变更之前留下的)不再需要操作者处理;下一个写入方无需等到期限即可继续。
  • 重启后被存活进程复用的 PID 会让锁保持原样,直到操作者移除,与之前相同。
  • 共享锁文件但不共享 PID 命名空间的进程,例如共享同一卷的容器或通过网络文件系统共享的多台主机,可能把存活持有者视为已退出。不支持此类部署。
  • 竞争者在持有认领文件时崩溃会留下该认领文件,对应的锁随后不会被自动接管。
  • 已退出操作留下的 pnpm 运行会阻止后续操作,直到它停止或操作者删除其记录。
  • 仍存活的持有者会保留其锁,因此本变更不限制存活操作的持锁时长。