fix(plugin-manager): wait for a pnpm run its exited operation left behind
Review found three ways the takeover could let two writers overlap. - A claimant that read an exited PID could remove the lock of a new holder that reused that PID before the claim. The claimant now probes the PID again under the claim, after re-reading the record. - The browser Worker's process shim reports its own PID as absent, so a second writer in the Worker took over the first one's live lock. A record naming the contender's own process is never taken over. - Takeover proves only that the holder exited, while a pnpm run it started can still be writing the profile. Each profile operation now records its pnpm run in .plugin-manager/run.json and removes the record when the run ends; an operation that finds a record waits for the run within the existing tree bound and otherwise refuses, naming the process and the record, without running pnpm. A failed removal of the exited holder's lock now waits instead of failing the acquisition, the probe range is stated as int32, and the tests inject exited PIDs instead of reusing real ones, which Windows reissues at once.
This commit is contained in:
1 parent
6dbf4ab6c9
commit
1bd3df926d
15 files changed
+386
-84
No files matched your search
@@ -9,14 +9,14 @@
|
||||
en: cf143bc56530fe17
|
||||
zh: a4e3ee8fa3b8c465
|
||||
/agent-note-taking-over-a-writer-lock-whose-holder-exited/decision:
|
||||
en: 88c935a0af049dfe
|
||||
zh: c548670bd18b6182
|
||||
en: af22bae72ff97ef8
|
||||
zh: 428c09871bba95d1
|
||||
/agent-note-taking-over-a-writer-lock-whose-holder-exited/alternatives-considered:
|
||||
en: 1700b835f724ebad
|
||||
zh: 2103d6e9780036e2
|
||||
en: ee800ed269cfc4a2
|
||||
zh: df983b552ef25273
|
||||
/agent-note-taking-over-a-writer-lock-whose-holder-exited/testing:
|
||||
en: ee0ca84c8b3a9ea9
|
||||
zh: a9a327edc91155af
|
||||
en: 7eb7c5818049415a
|
||||
zh: f84c39c55bb3bc5a
|
||||
/agent-note-taking-over-a-writer-lock-whose-holder-exited/consequences:
|
||||
en: ec11e04ff1516b0e
|
||||
zh: 9e5603db126be1d9
|
||||
en: 03f4cda9290188e5
|
||||
zh: 07f800776c061218
|
||||
@@ -13,9 +13,10 @@ The profile package lock (`<profile>/package.json.lock`) made the cost visible.
|
||||
## 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.
|
||||
- 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, removes it only if it still holds that record, removes the claim, and retries acquisition at once. The record can change only through another takeover, which needs the same claim, or through a new holder that reused the exited PID between the claimant's two reads; the latter requires the PID space to wrap within that moment, so a claimant does not remove 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 is waited for. None of these proves that the holder stopped.
|
||||
- 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](../../../../packages/boot/plugin-manager/README.md) 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
|
||||
@@ -26,7 +27,9 @@ The profile package lock (`<profile>/package.json.lock`) made the cost visible.
|
||||
|
||||
**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 only race it closes needs the PID space to wrap between two reads.
|
||||
**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.
|
||||
|
||||
@@ -34,7 +37,9 @@ The profile package lock (`<profile>/package.json.lock`) made the cost visible.
|
||||
|
||||
| Evidence | Behaviour |
|
||||
|---|---|
|
||||
| [atomic-write.spec.ts](../../../../packages/util/atomic-write/tests/atomic-write.spec.ts) | Takeover of an exited holder's record; eight contenders over one exited holder never overlap; live, other-user, empty, incomplete, non-PID, process-group, and out-of-range records are waited for; an unreadable lock, a claim held by another contender, and a record replaced after the claim are left in place; a Windows claim refusal is retried, another claim failure surfaces, and a claim that cannot be removed does not fail the operation. |
|
||||
| [atomic-write.spec.ts](../../../../packages/util/atomic-write/tests/atomic-write.spec.ts) | Takeover 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.ts](../../../../packages/boot/plugin-manager/tests/operations.spec.ts) | CLI, 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.ts](../../../../packages/boot/plugin-manager/tests/operations-process.spec.ts) | A real recorded run that is still writing finishes before the next operation's pnpm starts. |
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -42,4 +47,5 @@ The profile package lock (`<profile>/package.json.lock`) made the cost visible.
|
||||
- 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.
|
||||
@@ -13,9 +13,10 @@ profile 包锁(`<profile>/package.json.lock`)让这个代价显现出来。`
|
||||
## 决策
|
||||
|
||||
- 锁沿用早期版本写入的 `<pid>\n` 记录,因此本变更之前遗留的锁也会被接管,新旧版本的进程可以互操作。
|
||||
- 竞争者发现锁时读取其记录。若对该 PID 的信号探测以 `ESRCH` 失败,则证明持有者已不存在,锁会被接管。`EPERM` 表示该进程以其他用户身份存在,锁保留。
|
||||
- 读到同一条记录的竞争者通过一个以 `wx` 创建的认领文件 `<file>.lock.takeover-<记录 SHA-256 的前 16 位十六进制>` 串行化。认领者重新读取锁,仅当锁仍是该记录时才移除它,随后移除认领文件并立即重试获取。记录只能经由另一次接管改变(那需要同一个认领文件),或经由在认领者两次读取之间复用了该 PID 的新持有者改变;后者要求 PID 空间在这一瞬间回绕,因此认领者不会移除另一个竞争者在已退出持有者之后获得的锁。
|
||||
- 记录为空、不完整、不是十进制 PID 或指向 PID 0 时,竞争者继续等待。这些情况都无法证明持有者已经停止。
|
||||
- 竞争者发现锁时读取其记录。若对该 PID 的信号探测以 `ESRCH` 失败,则证明持有者已不存在,锁会被接管。`EPERM` 表示该进程以其他用户身份存在,锁保留。指向竞争者自身进程的记录同样保留:该进程正在运行,而浏览器 Worker 的进程模拟层会把自身 PID 报告为不存在。
|
||||
- 读到同一条记录的竞争者通过一个以 `wx` 创建的认领文件 `<file>.lock.takeover-<记录 SHA-256 的前 16 位十六进制>` 串行化。认领者重新读取锁并再次探测其 PID,仅当锁仍是该记录且该 PID 仍不存在时才移除它,随后移除认领文件并立即重试获取。PID 不存在时记录的持有者无法再释放它,另一次接管需要同一个认领文件,复用了该 PID 的新持有者无法通过第二次探测,因此认领者绝不会移除另一个竞争者在已退出持有者之后获得的锁。
|
||||
- 记录为空、不完整、不是十进制 PID,或指向 PID 0 或超出探测所接受的 int32 范围的 PID 时,竞争者继续等待。这些情况都无法证明持有者已经停止。
|
||||
- 接管只能证明记录中的进程已退出,不能证明它启动的进程已停止写入。[Plugin Manager](../../../../packages/boot/plugin-manager/README.zh.md) 在每次 pnpm 运行期间把它记录在 `.plugin-manager/run.json` 中;发现该记录的操作最多等待五秒让记录中的运行停止,否则拒绝运行,并指明该进程与记录文件。
|
||||
- 探测在竞争者所在主机上进行。不支持多台主机通过网络文件系统共享同一个 `DSH_HOME` 写入;保护 Session 文件的 `flock` 同样无法可靠地排除它们。
|
||||
|
||||
## 考虑过的替代方案
|
||||
@@ -26,7 +27,9 @@ profile 包锁(`<profile>/package.json.lock`)让这个代价显现出来。`
|
||||
|
||||
**把陈旧锁重命名到别处,若重命名后的记录不同再恢复。** 恢复可能与在此间隙获取空路径的第三个竞争者竞态,导致出现两个持有者。认领文件无需任何恢复即可防止这一竞态。
|
||||
|
||||
**记录主机名和随机数。** 主机名能让其他主机上的写入方不去探测不属于自己的 PID,但没有受支持的部署会跨主机共享这些文件,而且 macOS 切换网络时会改变主机名,导致变更前留下的锁无法接管。随机数能让每条记录唯一,但它唯一能关闭的竞态需要 PID 空间在两次读取之间回绕。
|
||||
**记录主机名和随机数。** 主机名能让其他主机上的写入方不去探测不属于自己的 PID,但没有受支持的部署会跨主机共享这些文件,而且 macOS 切换网络时会改变主机名,导致变更前留下的锁无法接管。随机数能让每条记录唯一,但认领后的第二次探测已经能排除复用了该 PID 的持有者。
|
||||
|
||||
**终止记录中的 pnpm 运行而不是拒绝。** 其 PID 可能已被无关进程复用,发送信号会杀死该进程。拒绝则把这种情况交给操作者,诊断会指明该进程与记录文件。
|
||||
|
||||
**只在 `dsh plugin` 中安装信号处理器。** 这只覆盖留下锁的其中一条路径,崩溃、强制退出和其他锁文件仍会遗留。
|
||||
|
||||
@@ -34,7 +37,9 @@ profile 包锁(`<profile>/package.json.lock`)让这个代价显现出来。`
|
||||
|
||||
| 证据 | 行为 |
|
||||
|---|---|
|
||||
| [atomic-write.spec.ts](../../../../packages/util/atomic-write/tests/atomic-write.spec.ts) | 接管已退出持有者的记录;八个竞争者同时接管一个已退出持有者时从不重叠;存活、其他用户、空、不完整、非 PID、进程组和超出安全整数范围的记录均继续等待;无法读取的锁、被另一竞争者持有的认领、认领后被替换的记录都保留原样;Windows 对认领的拒绝会重试,其他认领失败会抛出,认领无法移除时操作不失败。 |
|
||||
| [atomic-write.spec.ts](../../../../packages/util/atomic-write/tests/atomic-write.spec.ts) | 接管已退出持有者的记录,各平台通过注入的探测验证,POSIX 上另用真实已退出的进程验证;八个竞争者同时接管一个已退出持有者时从不重叠;存活、自身进程、其他用户、空、不完整、非 PID、进程组和超出范围的记录均继续等待;认领后复用了该 PID 的持有者、无法读取的锁、被另一竞争者持有的认领、认领后被替换的记录都保留原样;无法移除的锁会继续等待;以 `EPERM` 拒绝的认领会重试,其他认领失败会抛出,认领无法移除时操作不失败。 |
|
||||
| [operations.spec.ts](../../../../packages/boot/plugin-manager/tests/operations.spec.ts) | CLI、service 与修复运行在运行期间被记录,结束后删除记录;仍活动的记录中运行会在 pnpm 启动前拒绝操作;已停止运行的记录被删除;不指向任何运行的记录会拒绝操作。 |
|
||||
| [operations-process.spec.ts](../../../../packages/boot/plugin-manager/tests/operations-process.spec.ts) | 仍在写入的真实记录中运行会在下一个操作的 pnpm 启动前结束。 |
|
||||
|
||||
## 后果
|
||||
|
||||
@@ -42,4 +47,5 @@ profile 包锁(`<profile>/package.json.lock`)让这个代价显现出来。`
|
||||
- 重启后被存活进程复用的 PID 会让锁保持原样,直到操作者移除,与之前相同。
|
||||
- 共享锁文件但不共享 PID 命名空间的进程,例如共享同一卷的容器或通过网络文件系统共享的多台主机,可能把存活持有者视为已退出。不支持此类部署。
|
||||
- 竞争者在持有认领文件时崩溃会留下该认领文件,对应的锁随后不会被自动接管。
|
||||
- 已退出操作留下的 pnpm 运行会阻止后续操作,直到它停止或操作者删除其记录。
|
||||
- 仍存活的持有者会保留其锁,因此本变更不限制存活操作的持锁时长。
|
||||
@@ -24,8 +24,8 @@
|
||||
en: d5643a06f300c30a
|
||||
zh: 9348b6a7676a2849
|
||||
/deepseek-ai-dsh-plugin-manager/understand-the-implementation:
|
||||
en: 0cbf4501defc4fe0
|
||||
zh: 8382481b15f97181
|
||||
en: 1c737c375aeac163
|
||||
zh: 009d7f139a749c79
|
||||
/deepseek-ai-dsh-plugin-manager/further-exploration:
|
||||
en: c6538243d8702a0d
|
||||
zh: 04a82d923353c75f
|
||||
|
||||
@@ -89,7 +89,7 @@ The CLI exposes `dsh plugin --profile <profile> version-exemptions`, `allow-vers
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs. A service run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` alongside its exit status, is classified `timeout` whatever status the signal left behind, and is not retried on the next registry, which bounds how long one operation can hold the profile lock; the CLI inherits the terminal, captures no output, and stays unbounded for its operator to interrupt. A run completes when its process exits, and the pipes then drain under a bounded grace period, so a descendant that inherited them cannot hold the operation open. A terminated run stops its whole process tree and waits for it, because a lifecycle script outlives the pnpm process that started it (issue #4981). Dependency-only changes do not trigger configuration reloads.
|
||||
The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs. A service run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` alongside its exit status, is classified `timeout` whatever status the signal left behind, and is not retried on the next registry, which bounds how long one operation can hold the profile lock; the CLI inherits the terminal, captures no output, and stays unbounded for its operator to interrupt. A run completes when its process exits, and the pipes then drain under a bounded grace period, so a descendant that inherited them cannot hold the operation open. A terminated run stops its whole process tree and waits for it, because a lifecycle script outlives the pnpm process that started it (issue #4981). Each operation records the pnpm run it starts in `.plugin-manager/run.json` and removes the record when the run ends. A lock whose holder process exited is taken over by the next writer, but that process's pnpm tree can still be running, so an operation that finds a record waits up to five seconds for the recorded run to stop and otherwise fails with a diagnostic that names the process and the record, without running pnpm. Dependency-only changes do not trigger configuration reloads.
|
||||
|
||||
Results contain the last attempted stage, target, saved-state change, application status and error codes. Web dictionaries render management text; pnpm and Loader diagnostics remain unmodified. Unrelated pre-existing inactive entries return warnings; new or changed failures and inactive explicit enablement targets fail the operation. A failed or cancelled installation restores the manifest and lockfile it snapshotted before pnpm ran ([rationale](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md)); a failed removal retains its partial changes and diagnostics. Installations are tracked by request id until their call settles, so a cancellation names one run and joins its settlement without taking the profile lock. The CLI inherits authentication variables and terminal descriptors; service operations use a scrubbed environment and captured output. No invariant companion is published because the manager reads files and Loader state directly and owns no independent state projection.
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ CLI 提供 `dsh plugin --profile <profile> version-exemptions`、`allow-version
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,与退出状态一并报告 `timedOut`,不论信号留下什么退出状态都归类为 `timeout`,且不再转问下一个注册表,因此单次操作占用 profile 锁的时长有上界;CLI 继承终端、不捕获输出,因此不受此上界约束,由操作者中断。运行以进程退出为完成点,随后只在一个有界的宽限窗口内排空管道,因此继承管道的孙进程无法让操作挂起。被终止的运行会停止整棵进程树并等待其消失,因为生命周期脚本的存活时间超过启动它的 pnpm 进程(issue #4981)。仅依赖字段变化不会触发配置重载。
|
||||
服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,与退出状态一并报告 `timedOut`,不论信号留下什么退出状态都归类为 `timeout`,且不再转问下一个注册表,因此单次操作占用 profile 锁的时长有上界;CLI 继承终端、不捕获输出,因此不受此上界约束,由操作者中断。运行以进程退出为完成点,随后只在一个有界的宽限窗口内排空管道,因此继承管道的孙进程无法让操作挂起。被终止的运行会停止整棵进程树并等待其消失,因为生命周期脚本的存活时间超过启动它的 pnpm 进程(issue #4981)。每个操作把它启动的 pnpm 运行记录在 `.plugin-manager/run.json` 中,并在运行结束时删除该记录。持有者进程已退出的锁会被下一个写入方接管,但该进程的 pnpm 进程树可能仍在运行,因此发现记录的操作最多等待五秒让记录中的运行停止,否则不运行 pnpm,并以指明该进程与记录文件的诊断失败。仅依赖字段变化不会触发配置重载。
|
||||
|
||||
结果包含最后尝试的阶段、目标、磁盘变化、应用状态和错误码。Web 词典呈现管理文案;pnpm 与 Loader 的诊断保持原样。无关的已有故障作为警告返回;新出现、配置变化后的故障,以及显式启用目标未激活,都会使操作失败。失败或被取消的安装会恢复 pnpm 运行前快照的 manifest 与 lockfile([理由](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md));失败的删除保留部分改动和诊断。安装按 request id 跟踪到调用结束,因此取消只针对一次运行,并且不取 profile 锁就能等待它结束。CLI 继承认证环境和终端描述符;service 使用清理后的环境并捕获输出。管理器直接读取文件和 Loader 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
|
||||
import { parseInstallSpec } from './install-spec.ts'
|
||||
import { awaitTreeGone, leadsOwnGroup } from './run-tree.ts'
|
||||
import { awaitTreeGone, leadsOwnGroup, treeAlive, type RunTree } from './run-tree.ts'
|
||||
import { incompatiblePlugin } from './failure.ts'
|
||||
import type { IncompatiblePlugin, PackageResult, Registry } from './types.ts'
|
||||
export { setProfileVersionExemption, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot'
|
||||
@@ -195,6 +195,50 @@ function optionalFile(path: string): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/** Where an operation records the pnpm run it started, for a successor when the operation's own process ends first. */
|
||||
function runRecordPath(dir: string): string {
|
||||
return join(dir, '.plugin-manager', 'run.json')
|
||||
}
|
||||
|
||||
/** Record a started run; a successor that takes over the profile lock from an exited process waits for it. */
|
||||
async function recordRun(dir: string, tree: RunTree): Promise<void> {
|
||||
if (tree.pid === undefined) return
|
||||
await writeFileAtomic(runRecordPath(dir), `${JSON.stringify(tree)}\n`, { mode: 0o600, dirMode: 0o700 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Why this operation must not run: a record left by an operation whose process
|
||||
* ended mid-run names a run that is still writing the profile. The profile
|
||||
* lock is taken over once its holder exits, but its pnpm tree can outlive it.
|
||||
* A recorded run that stopped, within a bounded wait, has its record removed.
|
||||
* @param dir Profile directory.
|
||||
* @returns The diagnostic, or undefined when no recorded run is active.
|
||||
*/
|
||||
async function activeRecordedRun(dir: string): Promise<string | undefined> {
|
||||
const path = runRecordPath(dir)
|
||||
const text = optionalFile(path)
|
||||
if (text === undefined) return undefined
|
||||
let tree: RunTree | undefined
|
||||
try {
|
||||
const value: unknown = JSON.parse(text)
|
||||
const { pid, grouped } = (typeof value === 'object' && value !== null ? value : {}) as { pid?: unknown; grouped?: unknown }
|
||||
if (Number.isSafeInteger(pid) && (pid as number) > 0 && typeof grouped === 'boolean') tree = { pid: pid as number, grouped }
|
||||
} catch (error) {
|
||||
// Records are replaced atomically, so an unparsable one was written by something else and is reported below.
|
||||
void error
|
||||
}
|
||||
if (tree === undefined) {
|
||||
return `dsh: ${path} does not name a package run; delete it once no earlier package operation is still running in this profile\n`
|
||||
}
|
||||
await awaitTreeGone(tree)
|
||||
if (treeAlive(tree)) {
|
||||
return `dsh: process ${String(tree.pid)}, started by an earlier package operation whose own process ended, is still running in this profile; `
|
||||
+ `wait for it or stop it, then retry. If process ${String(tree.pid)} is not that package run, delete ${path}.\n`
|
||||
}
|
||||
await rm(path, { force: true })
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** pnpm can install plugins through any direct-dependency field. */
|
||||
function directDependencies(manifest: ProfileManifest): Record<string, string> {
|
||||
const extra = manifest as ProfileManifest & { devDependencies?: Record<string, string>; optionalDependencies?: Record<string, string> }
|
||||
@@ -241,15 +285,24 @@ export async function runProfilePnpm(
|
||||
context: PackageOperationContext, args: readonly string[], options: PackageOperationOptions,
|
||||
): Promise<PackageResult> {
|
||||
const dir = context.dir ?? resolveProfileDir(context.profile, context.home)
|
||||
const before = readProfileManifest('dsh', dir)
|
||||
const savedFiles = ['package.json', 'pnpm-lock.yaml'].map(name => ({ path: join(dir, name), text: optionalFile(join(dir, name)) }))
|
||||
const beforeDependencies = directDependencies(before)
|
||||
const installedBefore = new Map(Object.keys(beforeDependencies).map(name => [name, optionalFile(join(dir, 'node_modules', name, 'package.json'))]))
|
||||
// Before any profile file is read: a recorded run that is still active may be rewriting them.
|
||||
const active = await activeRecordedRun(dir)
|
||||
const logRoot = join(dir, '.plugin-manager', 'logs')
|
||||
await mkdir(logRoot, { recursive: true, mode: 0o700 })
|
||||
const logDir = await mkdtemp(join(logRoot, 'operation-'))
|
||||
const logPath = join(logDir, 'pnpm.log')
|
||||
const log = await open(logPath, 'wx', 0o600)
|
||||
if (active !== undefined) {
|
||||
await log.write(active)
|
||||
await log.close()
|
||||
options.onOutput?.(active, 'stderr')
|
||||
const bytes = Buffer.from(active)
|
||||
return { exitCode: 1, output: bytes.subarray(Math.max(0, bytes.length - options.outputBytes)).toString('utf8'), truncated: bytes.length > options.outputBytes, logPath }
|
||||
}
|
||||
const before = readProfileManifest('dsh', dir)
|
||||
const savedFiles = ['package.json', 'pnpm-lock.yaml'].map(name => ({ path: join(dir, name), text: optionalFile(join(dir, name)) }))
|
||||
const beforeDependencies = directDependencies(before)
|
||||
const installedBefore = new Map(Object.keys(beforeDependencies).map(name => [name, optionalFile(join(dir, 'node_modules', name, 'package.json'))]))
|
||||
let output = Buffer.alloc(0)
|
||||
let truncated = false
|
||||
const append = (bytes: Buffer): void => {
|
||||
@@ -309,6 +362,8 @@ export async function runProfilePnpm(
|
||||
buffer: false, stdin: options.execution === 'cli' ? 'inherit' : 'ignore', cancelSignal: options.signal === undefined
|
||||
? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal]),
|
||||
})
|
||||
// Listened for before the record is written, so an exit during that write is not missed.
|
||||
const exited = once(child.nodeChildProcess, 'exit').catch(() => undefined)
|
||||
let writes = Promise.resolve()
|
||||
/** `settled` records that the process outcome is known; `stalled` that the silence bound stopped the run. */
|
||||
const control = { settled: false, stalled: false }
|
||||
@@ -359,11 +414,12 @@ export async function runProfilePnpm(
|
||||
if (collectors.length > 0) armIdle()
|
||||
let exitCode: number
|
||||
try {
|
||||
await recordRun(dir, { pid: child.pid, grouped })
|
||||
// execa resolves its promise only once the piped stdio has ended, so the
|
||||
// process's own exit — the run's completion — is read from the raw child. A
|
||||
// spawn failure settles without one.
|
||||
const settled = Promise.allSettled([child])
|
||||
await Promise.race([once(child.nodeChildProcess, 'exit').catch(() => undefined), settled])
|
||||
await Promise.race([exited, settled])
|
||||
control.settled = true
|
||||
clearTimeout(idleTimer)
|
||||
// A stalled run stops its whole tree first, so the caller's rollback and lock
|
||||
@@ -448,10 +504,12 @@ export async function runProfilePnpm(
|
||||
await restore()
|
||||
const hadLockfile = savedFiles.some(file => file.path.endsWith('pnpm-lock.yaml') && file.text !== undefined)
|
||||
const repair = ['install', hadLockfile ? '--frozen-lockfile' : '--config.lockfile=false']
|
||||
const repaired = await execa(options.command ?? 'pnpm', [...options.args ?? [], ...repair], {
|
||||
const repairing = execa(options.command ?? 'pnpm', [...options.args ?? [], ...repair], {
|
||||
cwd: dir, env: environment, extendEnv: false, reject: false, stdin: 'ignore',
|
||||
...options.idleTimeoutMs === undefined ? {} : { timeout: options.idleTimeoutMs },
|
||||
})
|
||||
await recordRun(dir, { pid: repairing.pid, grouped: false })
|
||||
const repaired = await repairing
|
||||
exitCode = 1
|
||||
const restoration = repaired.exitCode === 0
|
||||
? 'restored package.json, pnpm-lock.yaml, and node_modules'
|
||||
@@ -467,6 +525,8 @@ export async function runProfilePnpm(
|
||||
} finally {
|
||||
control.settled = true
|
||||
clearTimeout(idleTimer)
|
||||
// This process saw the run end, so no successor has to wait for it.
|
||||
await rm(runRecordPath(dir), { force: true })
|
||||
await log.close()
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -43,6 +43,24 @@ function targetOf(pid: number, grouped: boolean, platform: NodeJS.Platform): num
|
||||
return platform !== 'win32' && grouped ? -pid : pid
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any member of a run's tree is still alive.
|
||||
* @param tree The run's process id and whether it leads its own group.
|
||||
* @param internals Injectable process operations.
|
||||
* @returns True while the probe finds the process or a group member.
|
||||
*/
|
||||
export function treeAlive(tree: RunTree, internals: RunTreeInternals = {}): boolean {
|
||||
const pid = tree.pid
|
||||
if (pid === undefined) return false
|
||||
const platform = internals.platform ?? process.platform
|
||||
const alive = internals.alive ?? ((target: number) => { process.kill(target, 0); return true })
|
||||
try {
|
||||
return alive(targetOf(pid, tree.grouped, platform))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until a terminated run's tree is gone, so the caller restores and
|
||||
* unlocks the profile only after the scripts it started stopped writing.
|
||||
@@ -51,19 +69,8 @@ function targetOf(pid: number, grouped: boolean, platform: NodeJS.Platform): num
|
||||
* @returns Fulfillment once no member remains, or the wait bound elapsed.
|
||||
*/
|
||||
export async function awaitTreeGone(tree: RunTree, internals: RunTreeInternals = {}): Promise<void> {
|
||||
const pid = tree.pid
|
||||
if (pid === undefined) return
|
||||
const platform = internals.platform ?? process.platform
|
||||
const alive = internals.alive ?? ((target: number) => { process.kill(target, 0); return true })
|
||||
const remaining = (): boolean => {
|
||||
try {
|
||||
return alive(targetOf(pid, tree.grouped, platform))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const deadline = Date.now() + (internals.waitMs ?? TREE_WAIT_MS)
|
||||
while (remaining()) {
|
||||
while (treeAlive(tree, internals)) {
|
||||
if (Date.now() >= deadline) return
|
||||
await new Promise(resolve => setTimeout(resolve, TREE_POLL_MS))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** The bounded drain and the tree stop are measured against real child processes. */
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
@@ -139,3 +140,23 @@ describe.skipIf(process.platform === 'win32')('a run with descendants', () => {
|
||||
expect(existsSync(lateFile)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('waits for a run recorded by an exited operation before starting its own', async () => {
|
||||
const { context, lateFile, options } = fixture(
|
||||
// The run reports whether the recorded run had finished writing when it started.
|
||||
'console.log(require("node:fs").existsSync(process.env.DSH_LATE_FILE) ? "started after" : "started before")', '',
|
||||
)
|
||||
const orphan = spawn(process.execPath, ['-e', `setTimeout(() => { require('node:fs').writeFileSync(${JSON.stringify(lateFile)}, 'late') }, 500)`], {
|
||||
stdio: 'ignore',
|
||||
})
|
||||
onTestFinished(async () => {
|
||||
try { orphan.kill('SIGKILL') } catch { /* the recorded run already exited */ }
|
||||
await awaitGone(orphan.pid as number)
|
||||
})
|
||||
const dir = join(context.home, 'profiles', 'test')
|
||||
mkdirSync(join(dir, '.plugin-manager'), { recursive: true })
|
||||
writeFileSync(join(dir, '.plugin-manager', 'run.json'), JSON.stringify({ pid: orphan.pid, grouped: false }))
|
||||
const outcome = await runProfilePnpm(context, ['list'], options)
|
||||
expect(outcome).toMatchObject({ exitCode: 0, output: 'started after\n' })
|
||||
expect(existsSync(join(dir, '.plugin-manager', 'run.json'))).toBe(false)
|
||||
})
|
||||
@@ -44,6 +44,18 @@ interface FakePnpm {
|
||||
const command = vi.hoisted(() => ({ run: vi.fn<(...args: unknown[]) => Promise<FakeOutcome> & FakeChild>() }))
|
||||
vi.mock('execa', () => ({ execa: (...args: unknown[]) => command.run(...args) }))
|
||||
|
||||
/** Whether the bounded wait for a recorded run returns at once; the wait itself is measured in run-tree.spec.ts. */
|
||||
const treeWait = vi.hoisted(() => ({ skip: false }))
|
||||
vi.mock('../src/run-tree.ts', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../src/run-tree.ts')>()
|
||||
return {
|
||||
...actual,
|
||||
awaitTreeGone: async (...args: Parameters<typeof actual.awaitTreeGone>) => {
|
||||
if (!treeWait.skip) await actual.awaitTreeGone(...args)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
function fixture() {
|
||||
const home = mkdtempSync(join(tmpdir(), 'manager-pnpm-'))
|
||||
const dir = join(home, 'profiles', 'test')
|
||||
@@ -73,7 +85,7 @@ function fixture() {
|
||||
if (argv.includes('--frozen-lockfile') || argv.includes('--config.lockfile=false')) return result(pnpm.repairExit, '')
|
||||
return result(pnpm.exitCode, pnpm.output, () => { pnpm.mutate(runDir) })
|
||||
})
|
||||
onTestFinished(() => { command.run.mockReset(); rmSync(home, { recursive: true, force: true }) })
|
||||
onTestFinished(() => { command.run.mockReset(); treeWait.skip = false; rmSync(home, { recursive: true, force: true }) })
|
||||
return { home, dir, context: { home, profile: 'test', installAnchor, cwd: home }, pnpm }
|
||||
}
|
||||
|
||||
@@ -581,6 +593,104 @@ it('initializes missing profiles under the same lock and reports initialization'
|
||||
expect(messages.filter(text => text.includes('initialized profile'))).toHaveLength(2)
|
||||
})
|
||||
|
||||
/** The record an operation leaves for the pnpm run it started. */
|
||||
function runRecord(dir: string): string {
|
||||
return join(dir, '.plugin-manager', 'run.json')
|
||||
}
|
||||
|
||||
/** A run that exits after `ms`, reading the profile's run record while it is still running. */
|
||||
function observingChild(dir: string, pid: number, ms = 100) {
|
||||
const stdout = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
const raw = new EventEmitter()
|
||||
const observed: { record: string | undefined } = { record: undefined }
|
||||
const done = new Promise<FakeOutcome>((resolve) => {
|
||||
setTimeout(() => {
|
||||
observed.record = existsSync(runRecord(dir)) ? readFileSync(runRecord(dir), 'utf8') : undefined
|
||||
stdout.end()
|
||||
stderr.end()
|
||||
raw.emit('exit', 0, null)
|
||||
resolve({ exitCode: 0, failed: false, stdout: '', stderr: '' })
|
||||
}, ms)
|
||||
})
|
||||
return { child: Object.assign(done, { stdout, stderr, nodeChildProcess: raw, pid }), observed }
|
||||
}
|
||||
|
||||
it.each([8192, 30])('refuses to run while a run recorded by an exited operation is still active (output bound %i)', async (outputBytes) => {
|
||||
const { dir, context } = fixture()
|
||||
treeWait.skip = true
|
||||
mkdirSync(join(dir, '.plugin-manager'), { recursive: true })
|
||||
const record = JSON.stringify({ pid: process.pid, grouped: false })
|
||||
writeFileSync(runRecord(dir), record)
|
||||
const messages: string[] = []
|
||||
const outcome = await runProfilePnpm(context, ['add', './extra'], {
|
||||
execution: 'service', outputBytes, onOutput: (text) => { messages.push(text) },
|
||||
})
|
||||
const diagnostic = readFileSync(outcome.logPath, 'utf8')
|
||||
expect(diagnostic).toContain(`process ${String(process.pid)}, started by an earlier package operation whose own process ended, is still running`)
|
||||
expect(diagnostic).toContain(`delete ${runRecord(dir)}`)
|
||||
expect(messages.join('')).toBe(diagnostic)
|
||||
expect(outcome).toMatchObject({ exitCode: 1, truncated: diagnostic.length > outputBytes })
|
||||
expect(outcome.output).toBe(diagnostic.slice(-outputBytes))
|
||||
expect(command.run).not.toHaveBeenCalled()
|
||||
expect(readFileSync(runRecord(dir), 'utf8')).toBe(record)
|
||||
})
|
||||
|
||||
it('removes the record of a recorded run that stopped and runs', async () => {
|
||||
const { dir, context } = fixture()
|
||||
const exited = 2_000_000_000
|
||||
const kill = process.kill.bind(process)
|
||||
vi.spyOn(process, 'kill').mockImplementation((target, signal) => {
|
||||
if (target === exited) throw Object.assign(new Error('ESRCH: injected'), { code: 'ESRCH' })
|
||||
return kill(target, signal)
|
||||
})
|
||||
onTestFinished(() => { vi.restoreAllMocks() })
|
||||
mkdirSync(join(dir, '.plugin-manager'), { recursive: true })
|
||||
writeFileSync(runRecord(dir), JSON.stringify({ pid: exited, grouped: false }))
|
||||
const outcome = await runProfilePnpm(context, ['add', './extra'], { execution: 'service', outputBytes: 8192 })
|
||||
expect(outcome.exitCode).toBe(0)
|
||||
expect(command.run).toHaveBeenCalledOnce()
|
||||
expect(existsSync(runRecord(dir))).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['not json', 'null', '{"pid":0,"grouped":false}', '{"pid":12,"grouped":"no"}'])(
|
||||
'refuses to run beside a run record that names no run: %s', async (record) => {
|
||||
const { dir, context } = fixture()
|
||||
mkdirSync(join(dir, '.plugin-manager'), { recursive: true })
|
||||
writeFileSync(runRecord(dir), record)
|
||||
const outcome = await runProfilePnpm(context, ['add', './extra'], { execution: 'service', outputBytes: 8192 })
|
||||
expect(outcome).toMatchObject({ exitCode: 1 })
|
||||
expect(outcome.output).toBe(`dsh: ${runRecord(dir)} does not name a package run; delete it once no earlier package operation is still running in this profile\n`)
|
||||
expect(command.run).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['cli', 'service'] as const)('records a %s run while it runs and removes the record once it ends', async (execution) => {
|
||||
const { dir, context } = fixture()
|
||||
const { child, observed } = observingChild(dir, 4242)
|
||||
command.run.mockImplementationOnce(() => child)
|
||||
const outcome = await runProfilePnpm(context, ['list'], { execution, outputBytes: 8192 })
|
||||
expect(outcome.exitCode).toBe(0)
|
||||
expect(JSON.parse(observed.record ?? 'null')).toEqual({
|
||||
pid: 4242, grouped: execution === 'service' && process.platform !== 'win32',
|
||||
})
|
||||
expect(existsSync(runRecord(dir))).toBe(false)
|
||||
})
|
||||
|
||||
it('records the install that repairs a refused installation', async () => {
|
||||
const { dir, context, pnpm } = fixture()
|
||||
pnpm.mutate = (target) => { installGuarded(target, 'incompatible') }
|
||||
const base = command.run.getMockImplementation() as (...call: unknown[]) => Promise<FakeOutcome> & FakeChild
|
||||
const repair = observingChild(dir, 4343)
|
||||
command.run.mockImplementation((...call: unknown[]) => {
|
||||
return (call[1] as readonly string[]).includes('--config.lockfile=false') ? repair.child : base(...call)
|
||||
})
|
||||
const outcome = await runProfilePnpm(context, ['add', 'incompatible'], { execution: 'service', outputBytes: 8192 })
|
||||
expect(outcome.exitCode).toBe(1)
|
||||
expect(JSON.parse(repair.observed.record ?? 'null')).toEqual({ pid: 4343, grouped: false })
|
||||
expect(existsSync(runRecord(dir))).toBe(false)
|
||||
})
|
||||
|
||||
it('terminates a run that stopped printing and reports the silence bound', async () => {
|
||||
const { context } = fixture()
|
||||
command.run.mockImplementationOnce(() => silentChild('installing\n'))
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
en: 725509a8b621c668
|
||||
zh: c11e3ffdf73af844
|
||||
/deepseek-ai-dsh-atomic-write/understand-the-implementation/write-path:
|
||||
en: 24d48491c0096e5f
|
||||
zh: 0b9d2e45eed7d10f
|
||||
en: 2e6561c9282f0c0d
|
||||
zh: 7f0e76f6dbdea501
|
||||
/deepseek-ai-dsh-atomic-write/understand-the-implementation/why-the-swap-stays-safe:
|
||||
en: 784e985c1fb3a25e
|
||||
zh: 4afda1b9bf5f29cc
|
||||
@@ -48,8 +48,8 @@
|
||||
en: 815e93723a98919c
|
||||
zh: 921da870f67e4033
|
||||
/deepseek-ai-dsh-atomic-write/known-limitations-and-deferred-work:
|
||||
en: db830fa94a503a00
|
||||
zh: cb58cb5aeadbf15e
|
||||
en: 5d0b395da8833bbb
|
||||
zh: 6191bab46d288be7
|
||||
/deepseek-ai-dsh-atomic-write/known-limitations-and-deferred-work/dev-note:
|
||||
en: d3461ceeaf041974
|
||||
zh: f59df2adbf52fed9
|
||||
@@ -83,7 +83,7 @@ The package is built on one separation: the atomic commit owns the swap, and the
|
||||
|
||||
`writeFileAtomic` writes a random-suffix sibling opened with exclusive create (`wx`), then renames it over the target. The exclusive open refuses to follow a symlink planted at a guessable temp path; the same-directory sibling keeps the rename on one filesystem; and the rename replaces a symlinked target itself instead of writing through to its referent. A Windows retry keeps the same complete sibling and uses bounded exponential backoff, so temporary use of the target by software outside the cooperative writer lock cannot turn a safe replacement into an immediate failure; the archived [retry decision record](../../../.agents/notes/archived/bug-fix/2026-08-29-windows-atomic-replace-retry.md) documents the original rationale and rejected alternatives.
|
||||
|
||||
`withFileLock` creates a `<filename>.lock` sibling with `wx`. `EEXIST` identifies contention directly; `EPERM` does so only when a fresh `lstat` confirms the lock path exists, covering Windows exclusive-create behavior without hiding an unrelated permission failure. The lock records its creator's PID as `<pid>\n` and is removed by the holder in a `finally`. A contender that reads a record whose PID a signal probe reports as absent (`ESRCH`) creates a `<filename>.lock.takeover-<record hash>` claim with `wx`, re-reads the lock, removes it only if it still holds the same record, removes the claim, and retries at once. Contenders that read the same record contend for one claim, so a takeover never removes a lock that another contender acquired after the exited holder's. The probe runs on the contender's host, so writers on several hosts sharing one file are unsupported. Contention backs off exponentially and fails when the per-call `waitMs` deadline (default two seconds) passes; the [takeover decision record](../../../.agents/notes/implemented/bug-fix/2026-09-24-exited-holder-lock-takeover.md) owns the rationale.
|
||||
`withFileLock` creates a `<filename>.lock` sibling with `wx`. `EEXIST` identifies contention directly; `EPERM` does so only when a fresh `lstat` confirms the lock path exists, covering Windows exclusive-create behavior without hiding an unrelated permission failure. The lock records its creator's PID as `<pid>\n` and is removed by the holder in a `finally`. A contender that reads a record whose PID a signal probe reports as absent (`ESRCH`) creates a `<filename>.lock.takeover-<record hash>` claim with `wx`, re-reads the lock and probes its PID again, removes it only if it still holds the same record and that PID is still absent, removes the claim, and retries at once. A holder that exists under another user (`EPERM`) and a record naming the contender's own process are waited for. Contenders that read the same record contend for one claim, and the second probe rejects a holder that reused the exited PID, so a takeover never removes a lock that another contender acquired after the exited holder's. Takeover proves only that the recorded process exited; an operation that starts other writers leaves its successor a way to find them, as the [Plugin Manager](../../boot/plugin-manager/README.md) does for its pnpm runs. Contention backs off exponentially and fails when the per-call `waitMs` deadline (default two seconds) passes; the [takeover decision record](../../../.agents/notes/implemented/bug-fix/2026-09-24-exited-holder-lock-takeover.md) owns the rationale.
|
||||
|
||||
### Why the swap stays safe
|
||||
|
||||
@@ -124,7 +124,8 @@ These limits define where the package is not the right tool. They are current pa
|
||||
|
||||
- **Atomic, not durable** — no `fsync` of the file or its directory, so after a crash the rename may be observed unwound. The file-backed stores here re-read and republish on boot, keeping durability the caller's policy.
|
||||
- **String content only** — no `Buffer` or stream form until a consumer needs one.
|
||||
- **Some orphaned locks require operator recovery** — a lock whose record is empty or incomplete, or names a PID a live process reused, stays in place; later writers time out without deleting it.
|
||||
- **Some orphaned locks require operator recovery** — a lock whose record is empty or incomplete, or names a PID a live process reused, stays in place; later writers time out without deleting it. A contender that ends between creating a claim and removing the lock leaves both `<filename>.lock` and `<filename>.lock.takeover-<record hash>`, and the operator removes both.
|
||||
- **One host and one PID namespace** — the probe runs on the contender's host. Writers on several hosts sharing a network filesystem, or containers sharing a volume, can see a live holder as exited, take over its lock, and write at the same time as it.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -83,7 +83,7 @@ Windows 在无法观察到锁时会对 `EPERM` 重试一次,因为持锁方可
|
||||
|
||||
`writeFileAtomic` 先以独占创建(`wx`)打开一个随机后缀的同级文件并写入内容,然后 rename 到目标上。独占打开拒绝跟随预先埋在可猜测临时路径上的符号链接;同目录兄弟文件保证 rename 落在同一文件系统上;rename 替换的是目标位置的符号链接本身,绝不写穿到该链接指向的文件。Windows 重试会保留同一份完整的兄弟文件,并采用有界指数退避,因此协作式写锁之外的软件瞬时占用目标时,不会让安全替换立即失败;已归档的[重试决策记录](../../../.agents/notes/archived/bug-fix/2026-08-29-windows-atomic-replace-retry.md)记录了最初的理由与被拒绝的替代方案。
|
||||
|
||||
`withFileLock` 以 `wx` 创建 `<filename>.lock` 同级文件。`EEXIST` 直接表示竞争;只有一次新的 `lstat` 确认锁路径存在时,`EPERM` 才表示竞争,从而兼容 Windows 的独占创建行为,又不掩盖无关的权限故障。锁以 `<pid>\n` 记录创建者的 PID,由持有者在 `finally` 中移除。竞争者读到的记录若经信号探测报告该 PID 不存在(`ESRCH`),就以 `wx` 创建 `<filename>.lock.takeover-<记录哈希>` 认领文件,重新读取锁,仅当锁仍是同一条记录时才移除它,随后移除认领文件并立即重试。读到同一条记录的竞争者争用同一个认领文件,因此接管绝不会移除另一个竞争者在已退出持有者之后获得的锁。探测在竞争者所在主机上进行,因此不支持多台主机上的写入方共享同一文件。竞争按指数退避,在每次调用声明的 `waitMs` 期限(默认两秒)过后失败;[接管决策记录](../../../.agents/notes/implemented/bug-fix/2026-09-24-exited-holder-lock-takeover.zh.md)负责说明理由。
|
||||
`withFileLock` 以 `wx` 创建 `<filename>.lock` 同级文件。`EEXIST` 直接表示竞争;只有一次新的 `lstat` 确认锁路径存在时,`EPERM` 才表示竞争,从而兼容 Windows 的独占创建行为,又不掩盖无关的权限故障。锁以 `<pid>\n` 记录创建者的 PID,由持有者在 `finally` 中移除。竞争者读到的记录若经信号探测报告该 PID 不存在(`ESRCH`),就以 `wx` 创建 `<filename>.lock.takeover-<记录哈希>` 认领文件,重新读取锁并再次探测其 PID,仅当锁仍是同一条记录且该 PID 仍不存在时才移除它,随后移除认领文件并立即重试。以其他用户身份存在的持有者(`EPERM`)和指向竞争者自身进程的记录会继续等待。读到同一条记录的竞争者争用同一个认领文件,第二次探测会排除复用了已退出 PID 的持有者,因此接管绝不会移除另一个竞争者在已退出持有者之后获得的锁。接管只能证明记录中的进程已退出;启动了其他写入者的操作要为后继者留下找到它们的途径,[Plugin Manager](../../boot/plugin-manager/README.zh.md) 对其 pnpm 运行就是这样做的。竞争按指数退避,在每次调用声明的 `waitMs` 期限(默认两秒)过后失败;[接管决策记录](../../../.agents/notes/implemented/bug-fix/2026-09-24-exited-holder-lock-takeover.zh.md)负责说明理由。
|
||||
|
||||
### 交换为何安全
|
||||
|
||||
@@ -124,7 +124,8 @@ Windows 在无法观察到锁时会对 `EPERM` 重试一次,因为持锁方可
|
||||
|
||||
- **原子但不保证持久**——不对文件或其所在目录做 `fsync`,因此崩溃后可能观察到 rename 被回退。此处的文件型存储在启动时重新读取并重新发布,把持久性留作调用方的策略。
|
||||
- **仅支持字符串内容**——在有消费方需要之前,不提供 `Buffer` 或流式形态。
|
||||
- **部分遗留锁需要操作者恢复**——锁记录为空或不完整,或其 PID 已被存活进程复用时,锁会留在原地;后续写入方超时也不会删除它。
|
||||
- **部分遗留锁需要操作者恢复**——锁记录为空或不完整,或其 PID 已被存活进程复用时,锁会留在原地;后续写入方超时也不会删除它。竞争者若在创建认领文件与移除锁之间结束,会同时留下 `<filename>.lock` 和 `<filename>.lock.takeover-<记录哈希>`,由操作者删除两者。
|
||||
- **单一主机与单一 PID 命名空间**——探测在竞争者所在主机上进行。多台主机通过网络文件系统共享、或多个容器共享同一卷时,写入方可能把存活持有者视为已退出,接管其锁并与之同时写入。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -112,8 +112,10 @@ function holderExited(record: string): boolean {
|
||||
// Any other content is being written or was cut short, which proves nothing about its holder.
|
||||
if (!/^\d+\n$/.test(record)) return false
|
||||
const pid = Number(record.trim())
|
||||
// PID 0 addresses the caller's process group, which proves nothing about one holder.
|
||||
if (!Number.isSafeInteger(pid) || pid === 0) return false
|
||||
// PID 0 addresses the caller's process group, and the probe accepts only positive int32 PIDs.
|
||||
if (pid === 0 || pid > 0x7fffffff) return false
|
||||
// This process is running; a runtime whose probe cannot see itself must not take over its own lock.
|
||||
if (pid === process.pid) return false
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return false
|
||||
@@ -136,11 +138,12 @@ async function readLockRecord(lockPath: string): Promise<string | undefined> {
|
||||
|
||||
/**
|
||||
* Remove the lock when its recorded holder exited. Contenders that read the
|
||||
* same record serialize on a claim file named after it, and the claimant
|
||||
* removes the lock only while it still holds that record: no other contender
|
||||
* can replace the record without the claim, so a removal never deletes a lock
|
||||
* another contender acquired after the dead holder's, unless that contender
|
||||
* reused the dead holder's PID within the moment between the two reads.
|
||||
* same record serialize on a claim file named after it. Under the claim, the
|
||||
* claimant re-reads the lock and probes its PID again, and removes it only
|
||||
* when it still holds that record and that PID is still gone: the record's
|
||||
* holder can no longer release it, and no other contender can remove it
|
||||
* without the claim, so a removal never deletes a lock another contender
|
||||
* acquired after the dead holder's, including one whose holder reused the PID.
|
||||
* @returns Whether this call removed the dead holder's lock.
|
||||
*/
|
||||
async function takeOverExitedLock(lockPath: string): Promise<boolean> {
|
||||
@@ -156,8 +159,14 @@ async function takeOverExitedLock(lockPath: string): Promise<boolean> {
|
||||
throw error
|
||||
}
|
||||
try {
|
||||
if (await readLockRecord(lockPath) !== record) return false
|
||||
await rm(lockPath, { force: true })
|
||||
if (await readLockRecord(lockPath) !== record || !holderExited(record)) return false
|
||||
try {
|
||||
await rm(lockPath, { force: true })
|
||||
} catch (error) {
|
||||
// Windows can refuse the removal while other software briefly holds the file; the next attempt retries.
|
||||
void error
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} finally {
|
||||
await rm(claim, { force: true }).catch((error: unknown) => {
|
||||
@@ -208,12 +217,16 @@ export interface FileLockOptions {
|
||||
* Windows exclusive-create behavior. Windows retries one unconfirmed EPERM
|
||||
* because the holder can release before the probe; a repeated unconfirmed
|
||||
* permission error is rethrown. The lock records its holder's PID. A contender
|
||||
* removes the lock and retries at once when no process with that PID exists;
|
||||
* any other lock, including one whose record is incomplete, is waited for.
|
||||
* Contention backs off exponentially and times out after the deadline. A
|
||||
* holder whose PID a live process reused keeps its lock until an operator
|
||||
* removes it. PIDs are compared on the contender's host, so writers on other
|
||||
* hosts sharing the file are unsupported. The parent directory must exist.
|
||||
* removes the lock and retries at once when no process with that PID exists
|
||||
* (`ESRCH`); any other lock, including one whose holder exists under another
|
||||
* user (`EPERM`) or whose record is incomplete, is waited for. Contention backs
|
||||
* off exponentially and times out after the deadline. A holder whose PID a
|
||||
* live process reused keeps its lock until an operator removes it. Takeover
|
||||
* proves only that the recorded process exited: an operation that starts other
|
||||
* writers must stop them with it or leave its successor a way to find them.
|
||||
* PIDs are compared on the contender's host, so writers on other hosts or in
|
||||
* other PID namespaces sharing the file are unsupported and could both hold
|
||||
* the lock. The parent directory must exist.
|
||||
* @param filename - the file whose writers this lock serializes.
|
||||
* @param operation - the read-render-commit cycle to run while holding the lock.
|
||||
* @param options - acquisition options; omitted waits {@link DEFAULT_LOCK_WAIT_MS}.
|
||||
|
||||
@@ -11,6 +11,7 @@ const state = vi.hoisted(() => ({
|
||||
afterClaim: undefined as ((claim: string) => Promise<void>) | undefined,
|
||||
claimFailure: undefined as string | undefined,
|
||||
claimRemovalFails: false,
|
||||
lockRemovalFails: false,
|
||||
lockPermissionFailures: 0,
|
||||
releaseLockBeforeProbe: false,
|
||||
renameAttempts: 0,
|
||||
@@ -34,6 +35,9 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
if (state.claimRemovalFails && String(args[0]).includes('.lock.takeover-')) {
|
||||
throw Object.assign(new Error('EBUSY: injected claim removal failure'), { code: 'EBUSY' })
|
||||
}
|
||||
if (state.lockRemovalFails && String(args[0]).endsWith('.lock')) {
|
||||
throw Object.assign(new Error('EBUSY: injected lock removal failure'), { code: 'EBUSY' })
|
||||
}
|
||||
return actual.rm(...args)
|
||||
}),
|
||||
writeFile: (async (path: unknown, ...rest: never[]) => {
|
||||
@@ -63,6 +67,7 @@ afterEach(async () => {
|
||||
state.afterClaim = undefined
|
||||
state.claimFailure = undefined
|
||||
state.claimRemovalFails = false
|
||||
state.lockRemovalFails = false
|
||||
state.lockPermissionFailures = 0
|
||||
state.releaseLockBeforeProbe = false
|
||||
state.renameAttempts = 0
|
||||
@@ -81,11 +86,22 @@ async function scratch(): Promise<string> {
|
||||
return dir
|
||||
}
|
||||
|
||||
/** The PID of a process that has already exited. */
|
||||
async function exitedPid(): Promise<number> {
|
||||
const child = spawn(process.execPath, ['-e', ''], { stdio: 'ignore' })
|
||||
await once(child, 'exit')
|
||||
return child.pid as number
|
||||
/**
|
||||
* A PID the probe reports as absent. A real exited PID can be reissued while
|
||||
* the test runs, which Windows does almost at once.
|
||||
*/
|
||||
const EXITED_PID = 2_000_000_000
|
||||
|
||||
/** Make the signal probe report `pids` as absent, `responses` times each when given. */
|
||||
function probeExited(pids: readonly number[] = [EXITED_PID], responses = Infinity): void {
|
||||
const kill = process.kill.bind(process)
|
||||
let remaining = responses
|
||||
vi.spyOn(process, 'kill').mockImplementation((target, signal) => {
|
||||
if (!pids.includes(target)) return kill(target, signal)
|
||||
if (remaining <= 0) return true
|
||||
remaining -= 1
|
||||
throw Object.assign(new Error('ESRCH: injected'), { code: 'ESRCH' })
|
||||
})
|
||||
}
|
||||
|
||||
/** The lock record a holder writes. */
|
||||
@@ -251,19 +267,32 @@ describe('withFileLock', () => {
|
||||
})
|
||||
|
||||
it('takes over a lock whose holder exited', async () => {
|
||||
probeExited()
|
||||
const dir = await scratch()
|
||||
const target = join(dir, 'document')
|
||||
await writeFile(`${target}.lock`, record(await exitedPid()))
|
||||
await writeFile(`${target}.lock`, record(EXITED_PID))
|
||||
|
||||
await expect(withFileLock(target, async () => await readFile(`${target}.lock`, 'utf8'), { waitMs: 0 }))
|
||||
.resolves.toBe(record(process.pid))
|
||||
expect(await readdir(dir)).toEqual([])
|
||||
})
|
||||
|
||||
it('admits one contender at a time when several take over the same exited holder', async () => {
|
||||
// Windows reissues an exited PID almost at once, so the real probe runs on POSIX only.
|
||||
it.skipIf(process.platform === 'win32')('takes over the lock of a process that really exited', async () => {
|
||||
const dir = await scratch()
|
||||
const target = join(dir, 'document')
|
||||
await writeFile(`${target}.lock`, record(await exitedPid()))
|
||||
const child = spawn(process.execPath, ['-e', ''], { stdio: 'ignore' })
|
||||
await once(child, 'exit')
|
||||
await writeFile(`${target}.lock`, record(child.pid as number))
|
||||
|
||||
await expect(withFileLock(target, async () => 'acquired', { waitMs: 0 })).resolves.toBe('acquired')
|
||||
})
|
||||
|
||||
it('admits one contender at a time when several take over the same exited holder', async () => {
|
||||
probeExited()
|
||||
const dir = await scratch()
|
||||
const target = join(dir, 'document')
|
||||
await writeFile(`${target}.lock`, record(EXITED_PID))
|
||||
let active = 0
|
||||
let overlapped = false
|
||||
|
||||
@@ -278,11 +307,13 @@ describe('withFileLock', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a live holder', () => record(process.pid)],
|
||||
['a live holder', () => record(process.ppid)],
|
||||
['this process', () => record(process.pid)],
|
||||
['an empty record', () => ''],
|
||||
['an incomplete record', () => '12'],
|
||||
['a record that is not a PID', () => 'holder\n'],
|
||||
['a record naming a process group', () => record(0)],
|
||||
['a record beyond the int32 range', () => '2147483648\n'],
|
||||
['a record beyond the safe integer range', () => '99999999999999999999\n'],
|
||||
])('waits for the lock of %s', async (_label, render) => {
|
||||
const dir = await scratch()
|
||||
@@ -299,17 +330,58 @@ describe('withFileLock', () => {
|
||||
it('waits for a holder whose process exists under another user', async () => {
|
||||
const dir = await scratch()
|
||||
const target = join(dir, 'document')
|
||||
const pid = await exitedPid()
|
||||
await writeFile(`${target}.lock`, record(pid))
|
||||
await writeFile(`${target}.lock`, record(EXITED_PID))
|
||||
const kill = process.kill.bind(process)
|
||||
vi.spyOn(process, 'kill').mockImplementation((target, signal) => {
|
||||
if (target === pid) throw Object.assign(new Error('EPERM: injected'), { code: 'EPERM' })
|
||||
if (target === EXITED_PID) throw Object.assign(new Error('EPERM: injected'), { code: 'EPERM' })
|
||||
return kill(target, signal)
|
||||
})
|
||||
|
||||
await expect(withFileLock(target, async () => {}, { waitMs: 50 })).rejects.toThrow(/timed out waiting for the writer lock/)
|
||||
})
|
||||
|
||||
it('keeps its own lock when the runtime cannot probe itself', async () => {
|
||||
// The browser Worker's process shim reports its own PID as absent.
|
||||
probeExited([process.pid])
|
||||
const dir = await scratch()
|
||||
const target = join(dir, 'document')
|
||||
let release = (): void => {}
|
||||
const holder = withFileLock(target, () => new Promise<void>((resolve) => { release = resolve }))
|
||||
await waitForLock(`${target}.lock`)
|
||||
|
||||
await expect(withFileLock(target, async () => {}, { waitMs: 50 })).rejects.toThrow(/timed out waiting for the writer lock/)
|
||||
release()
|
||||
await holder
|
||||
})
|
||||
|
||||
it('keeps a lock whose holder reused the exited PID while this contender claimed it', async () => {
|
||||
probeExited([EXITED_PID], 1)
|
||||
const dir = await scratch()
|
||||
const target = join(dir, 'document')
|
||||
const held = record(EXITED_PID)
|
||||
await writeFile(`${target}.lock`, held)
|
||||
state.afterClaim = async () => {
|
||||
state.afterClaim = undefined
|
||||
await rm(`${target}.lock`)
|
||||
await writeFile(`${target}.lock`, held)
|
||||
}
|
||||
|
||||
await expect(withFileLock(target, async () => {}, { waitMs: 50 })).rejects.toThrow(/timed out waiting for the writer lock/)
|
||||
expect(await readFile(`${target}.lock`, 'utf8')).toBe(held)
|
||||
})
|
||||
|
||||
it('waits when the exited holder\'s lock cannot be removed', async () => {
|
||||
probeExited()
|
||||
const dir = await scratch()
|
||||
const target = join(dir, 'document')
|
||||
await writeFile(`${target}.lock`, record(EXITED_PID))
|
||||
state.lockRemovalFails = true
|
||||
const operation = vi.fn(async () => {})
|
||||
|
||||
await expect(withFileLock(target, operation, { waitMs: 50 })).rejects.toThrow(/timed out waiting for the writer lock/)
|
||||
expect(operation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('waits for a lock it cannot read', async () => {
|
||||
const dir = await scratch()
|
||||
const target = join(dir, 'document')
|
||||
@@ -321,7 +393,8 @@ describe('withFileLock', () => {
|
||||
it('leaves an exited holder\'s lock to the contender that claimed its record', async () => {
|
||||
const dir = await scratch()
|
||||
const target = join(dir, 'document')
|
||||
const held = record(await exitedPid())
|
||||
probeExited()
|
||||
const held = record(EXITED_PID)
|
||||
await writeFile(`${target}.lock`, held)
|
||||
const claim = `${target}.lock.takeover-${createHash('sha256').update(held).digest('hex').slice(0, 16)}`
|
||||
await writeFile(claim, '1\n')
|
||||
@@ -331,9 +404,10 @@ describe('withFileLock', () => {
|
||||
})
|
||||
|
||||
it('keeps a lock that another contender acquired while this one claimed the exited record', async () => {
|
||||
probeExited()
|
||||
const dir = await scratch()
|
||||
const target = join(dir, 'document')
|
||||
await writeFile(`${target}.lock`, record(await exitedPid()))
|
||||
await writeFile(`${target}.lock`, record(EXITED_PID))
|
||||
const successor = record(process.pid)
|
||||
state.afterClaim = async () => {
|
||||
state.afterClaim = undefined
|
||||
@@ -345,10 +419,11 @@ describe('withFileLock', () => {
|
||||
expect(await readdir(dir)).toEqual(['document.lock'])
|
||||
})
|
||||
|
||||
it('retries after Windows refuses a claim that is still being deleted', async () => {
|
||||
it('retries after a claim is refused with EPERM', async () => {
|
||||
probeExited()
|
||||
const dir = await scratch()
|
||||
const target = join(dir, 'document')
|
||||
await writeFile(`${target}.lock`, record(await exitedPid()))
|
||||
await writeFile(`${target}.lock`, record(EXITED_PID))
|
||||
state.claimFailure = 'EPERM'
|
||||
setTimeout(() => { state.claimFailure = undefined }, 30)
|
||||
|
||||
@@ -356,9 +431,10 @@ describe('withFileLock', () => {
|
||||
})
|
||||
|
||||
it('surfaces a claim failure that is not contention', async () => {
|
||||
probeExited()
|
||||
const dir = await scratch()
|
||||
const target = join(dir, 'document')
|
||||
await writeFile(`${target}.lock`, record(await exitedPid()))
|
||||
await writeFile(`${target}.lock`, record(EXITED_PID))
|
||||
state.claimFailure = 'EIO'
|
||||
const operation = vi.fn(async () => {})
|
||||
|
||||
@@ -367,9 +443,10 @@ describe('withFileLock', () => {
|
||||
})
|
||||
|
||||
it('runs the operation when its claim cannot be removed afterwards', async () => {
|
||||
probeExited()
|
||||
const dir = await scratch()
|
||||
const target = join(dir, 'document')
|
||||
await writeFile(`${target}.lock`, record(await exitedPid()))
|
||||
await writeFile(`${target}.lock`, record(EXITED_PID))
|
||||
state.claimRemovalFails = true
|
||||
|
||||
await expect(withFileLock(target, async () => 'acquired', { waitMs: 0 })).resolves.toBe('acquired')
|
||||
|
||||
Reference in new issue
Block a user