Bounding a pnpm run that stops progressing
为停止推进的 pnpm 运行设界
A profile package operation could hold the profile write lock forever. A `dsh web` process held `~/.dsh/profiles/web/package.json.lock` for 22 minutes after its pnpm child printed `Done in 2s`: that child never exited, so the operation awaited an end that never came, the lock was never released, and every later management call in the process queued behind it. pnpm 11.13.0 consumes its one-shot worker-pool teardown on
English
Problem
A profile package operation could hold the profile write lock forever. A dsh web process held ~/.dsh/profiles/web/package.json.lock for 22 minutes after its pnpm child printed Done in 2s: that child never exited, so the operation awaited an end that never came, the lock was never released, and every later management call in the process queued behind it. pnpm 11.13.0 consumes its one-shot worker-pool teardown once, re-creates the pool lazily afterwards, and unreferences a worker only during destruction, so the pool keeps the parent process alive with no repository-side change able to fix it.
A second cause reaches the same end by another route: a lifecycle script that inherited pnpm's stdout and stderr keeps those pipes open after pnpm exits, so a reader waiting for end of file never sees one, and the lock stays held while that reader waits.
Decision
- A run completes when its process exits, not when its pipes end. The operation awaits the raw child's
exitevent and races execa's promise, which resolves only once the piped stdio closes. - The pipes then drain under a fixed 2000 ms grace period, after which this operation closes them. Failure classification reads a tail pnpm writes before it exits, so the grace period belongs to finishing a run rather than to deployment configuration.
- Only the close this drain causes is ignored. A reading that failed before the cut still surfaces as the run's failure, and the log records that the tail was cut short.
- A run whose captured output stays silent for
idleTimeoutMsis terminated, reportstimedOutbeside its own exit status, and is not asked of the next registry, which bounds how long one operation can hold the lock. - A terminated run is classified
timeoutwhatever exit status the signal left behind. Installation and removal report failure instead of success, so a manifest pnpm had half written is restored rather than activated. - A terminated run stops its whole process tree and waits for it to disappear before the caller restores the profile and releases the lock, because an approved lifecycle script runs as a child of pnpm and outlives it. execa's
killDescendantsaddresses the tree on every kill path, including cancellation, and is passed only for a captured run, so a run that inherits the terminal keeps the caller's process group. dsh plugininherits the caller's terminal and captures nothing, so no silence bound applies to it and its operator keeps the ability to interrupt.
Why a silence bound rather than a duration bound
A capture-only run that prints nothing for ten minutes is stuck: pnpm reports its own progress and its scripts' output there. A total duration bound would instead kill legitimate work, because a non-TTY pnpm prints a build script's output when that script finishes, and a native build on a slow machine compiles silently for longer than any fixed budget we could justify for it. Silence separates a stalled run from a slow one without guessing a duration, and 600000 ms leaves an approved build ten times the slowest silent stretch maintainers expect from it.
Alternatives considered
Wait for execa's promise. execa resolves after the piped stdio ends, which is the end a held pipe withholds, so the completion signal had to move to the process itself.
Bound total duration instead of silence. A stalled run and a compiling run are indistinguishable by duration alone, and the bound would destroy legitimate work whenever the machine is slow enough.
Terminate only the pnpm process. An approved postinstall keeps writing into the profile after the operation reports a timeout and releases the lock, so a later restoration races that script. Reproduced with a local dependency whose postinstall writes a file, and with pnpm 11.13.0.
Detach the run and reap the tree later. Rollback and lock release must happen after the tree stopped writing, and deferring the reap leaves open exactly the window this decision closes.
Keep the pipes as the completion signal and bound only that wait. The drain does bound the wait, but a stalled worker pool never ends its pipes, so the process exit remains the completion signal.
Testing
| Evidence | Behaviour |
|---|---|
| operations.spec.ts | Silence-bound termination, a run that trapped the signal and exited 0, the bounded drain over a held pipe, a reading failure that still surfaces through the cut, a rejection reason that is not an Error, and the cut notice. |
| operations-process.spec.ts | Real descendants: a pipe that outlives its process still drains, and a stalled run's tree is stopped before the operation returns, so a marker its script would have written never appears. |
| run-tree.spec.ts | Platform group-leadership decisions, the probe target each platform addresses, and the wait bound. |
| manager.spec.ts | A terminated run that exited 0 restores the manifest, reports timeout, and is not activated; a terminated removal reports failure. |
The real-process cases cover POSIX only: Windows cannot stage a descendant that keeps inherited pipes open past its parent, and the drain itself is platform-independent. With those cases skipped, the mocked cases still hold the per-file coverage gate on Windows.
Related
Guided plugin installation remains the owner of what an installation or removal does with a finished run: the manifest snapshot it restores, the classification it renders, and the cancellation contract. This note owns only how a run ends and how long it may hold the profile lock.
Consequences
- A stalled run is reported up to
idleTimeoutMslate and the profile lock is held for that long. A silent but healthy native build longer than the default is killed and classifiedtimeout; a profile that compiles such a dependency raisesidleTimeoutMs, trading slower stall detection for tolerance. - A cut tail is incomplete, so the failure classification and the returned output see only what pnpm wrote inside the grace period. The diagnostic log records the cut.
- Terminating a run kills its whole tree, so a lifecycle script that had already started is stopped instead of being allowed to finish.
- The bound is a workaround for a third-party defect (issue #4981, PR #4982) and becomes unnecessary once pnpm tears down its worker pool before a command returns.
中文
问题
一次 profile 包操作可能永久持有 profile 写锁。某个 dsh web 进程在其 pnpm 子进程打印 Done in 2s 之后仍持有 ~/.dsh/profiles/web/package.json.lock 达 22 分钟:该子进程从未退出,于是操作等待一个永不到来的结束点,锁始终未释放,进程内后续每一次管理调用都排在它后面。pnpm 11.13.0 只消费一次性的 worker 池收尾,之后又惰性重建该池,并且只在销毁时才对 worker 取消引用,因此该池会让父进程一直存活,仓库侧无从修复。
第二条成因以另一条路径导致同一结果:继承了 pnpm stdout 与 stderr 的生命周期脚本会在 pnpm 退出后继续持有这些管道,于是等待文件结束的读取方永远等不到,锁在读等期间一直被持有。
决策
- 运行的完成点是进程退出,而非管道结束。操作等待原始子进程的
exit事件,并与 execa 的 promise 竞速;后者只在管道化 stdio 关闭后才 resolve。 - 随后管道在一个固定的 2000 毫秒宽限窗口内排空,窗口结束后由本次操作关闭它们。失败分类读取的是 pnpm 退出前写入的尾部,因此该宽限属于「完成一次运行」,而不是部署配置项。
- 只有本次排空造成的关闭才被忽略。切断之前的读取失败仍作为该运行的失败上报,日志会记录尾部被切断。
- 捕获输出的运行若在
idleTimeoutMs内没有任何输出即被终止,在自身退出状态之外报告timedOut,且不再询问下一个注册表,从而使单次操作持有锁的时长有上界。 - 被终止的运行不论信号留下什么退出状态都归类为
timeout。安装与删除都报告失败而非成功,因此 pnpm 只写了一半的 manifest 会被还原,而不会被启用。 - 被终止的运行会停止整棵进程树并等待其消失,之后调用方才还原 profile 并释放锁,因为获批的生命周期脚本是 pnpm 的子进程且存活更久。execa 的
killDescendants覆盖每一条 kill 路径(含取消),且只对捕获输出的运行启用,因此继承终端的运行仍留在调用方的进程组内。 dsh plugin继承调用方终端、不捕获输出,因此不受静默上界约束,其操作者仍可中断。
为何用静默上界而非时长上界
只捕获输出、十分钟没有任何输出的运行已经卡住:pnpm 自身的进度与脚本输出都写在那里。而总时长上界会误杀正当工作,因为非 TTY 的 pnpm 只在该脚本结束时才打印它的输出,慢机器上的原生构建以超过任何可辩护固定预算的时间静默编译。静默把卡住的运行与较慢的运行区分开,无需猜测时长;600000 毫秒给获批构建留下的静默余量,是维护者预期的最慢静默时长的十倍。
考虑过的替代方案
等待 execa 的 promise。 execa 在管道化 stdio 结束后才 resolve,而那正是被持有管道扣住不发的结束点,因此完成信号必须移到进程本身。
改为对总时长设界。 仅凭时长无法区分卡住的运行与正在编译的运行,而且只要机器够慢,这个上界就会破坏正当工作。
只终止 pnpm 进程。 获批的 postinstall 会在操作报告超时并释放锁之后继续写入 profile,于是后续的还原与该脚本互相竞争。已用 pnpm 11.13.0 与一个 postinstall 会写文件的本地依赖复现。
让运行脱离父进程、稍后再回收进程树。 还原与释放锁必须在进程树停止写入之后发生,推迟回收恰好留下本决策要关闭的那个窗口。
保留管道作为完成信号,只对该等待设界。 排空确实为等待设了界,但卡住的 worker 池永不结束其管道,因此进程退出仍是完成信号。
验证
| 证据 | 覆盖行为 |
|---|---|
| operations.spec.ts | 静默上界终止、捕获信号后以 0 退出的运行、持有管道下的有界排空、经切断仍上报的读取失败、非 Error 的拒绝原因,以及切断提示。 |
| operations-process.spec.ts | 真实孙进程:存活超过其父进程的管道仍能排空;卡住运行的进程树在操作返回前被停止,因此其脚本本应写入的标记不会出现。 |
| run-tree.spec.ts | 各平台的进程组归属判定、各平台探测的目标,以及等待上界。 |
| manager.spec.ts | 以 0 退出的被终止运行会还原 manifest、报告 timeout 且不被启用;被终止的删除报告失败。 |
真进程用例仅覆盖 POSIX:Windows 无法搭出「孙进程在其父进程退出后仍持有继承管道」的前提,而排空本身与平台无关。跳过这些用例时,mock 用例仍能在 Windows 上满足逐文件覆盖率门禁。
相关
引导式插件安装仍是「安装或删除如何处理一次已结束运行」的归属:它还原的 manifest 快照、渲染的分类与取消约定。本记录只负责运行如何结束,以及它可以持有 profile 锁多久。
后果
- 卡住的运行最迟在
idleTimeoutMs之后才被报告,profile 锁在这段时间内一直被持有。静默但健康、且超过默认值的原生构建会被终止并归类为timeout;需要编译此类依赖的 profile 应提高idleTimeoutMs,以更慢的卡住检测换取容忍度。 - 被切断的尾部不完整,因此失败分类与返回输出只看到 pnpm 在宽限窗口内写入的内容。诊断日志记录了这次切断。
- 终止运行会杀掉整棵进程树,因此已经开始的生命周期脚本会被停止,而不会被允许跑完。
- 该上界是针对第三方缺陷的变通(issue #4981、PR #4982);一旦 pnpm 在命令返回前完成其 worker 池收尾,它就不再必要。