停一个随时在变的进程——清理动作里的 TOCTOU 与连续检查点
Pier 3.0 复盘:绿色判定用的是诊断那一刻的快照,可从用户点下清理到信号真正发出,进程可能长出新连接、新子进程。讲讲怎么用「连续检查点 + 2 秒证据时限 + 发信号前复验内核启动时间」把这个检查与执行之间的窗口收到最小。
Pier 3.0 判定一个开发残留「可以安全停掉」,靠的是诊断那一刻采集的证据——没有 live connection、进程树已孤儿化、身份匹配。但这里藏着一个经典的并发陷阱:判定和执行之间隔着时间。用户看到绿色卡片、思考几秒、点下「安全收尾」,再到 SIGTERM 真正发出——这段窗口里,那个「闲置的」dev server 完全可能刚被浏览器连上、刚 fork 出一个新子进程。停掉它,就是网络监视器复盘里那种「用户正看着的页面突然断了」。
这是 TOCTOU(Time-Of-Check to Time-Of-Use,检查时与使用时不一致)。对一个会真的去 kill 进程的工具,这个窗口就是安全的命门。这篇讲怎么把它收到最小。
最强的护栏:live connection
先说最重要的一条判据。3.0 里最强的安全护栏是 live connection 检测,规则一句话:
只要监听端口在采样窗口内出现任何 established 连接,该资源就不能进绿色。
它挡的正是最容易误杀、代价最高的一类:用户正在浏览器里看的页面、前端正连着的 HMR / WebSocket、API 调试工具正在打的接口、别的本地进程正在用的服务。有人在连,就绝不碰——哪怕它看起来像个再普通不过的 Vite dev server。
一次扫描不够,要连续检查点
天真的实现是:诊断时扫一次连接,没有就放进绿色,点击时直接 kill。但这正好踩中 TOCTOU——诊断那次扫描的结论,到点击时早就可能过期了。
3.0 的做法是把「检查」从一个时间点摊成一串连续检查点,夹住那些可能变化的状态:
- 诊断刷新时采集连接证据,决定资源能不能显示为绿色;
- 用户确认后重新诊断一次——不信任几秒前那张卡片。新诊断若失败、项目安全级别变了、选中的资源变了、或身份证据对不上,直接拒绝执行,让用户回去看最新状态;
- 发送每一个信号之前,用「端口与连接 → 采样 CPU/IO 活动 → 再次端口与连接」把可能变化的网络状态夹起来——在两次网络检查之间才去做耗时的活动采样,确保采样前后连接状态都干净;
- 第二轮网络检查后,继续校验进程树、以及整个组件里所有仍存活节点的稳定身份;
- 从第一轮检查到真正发信号,不得超过 2 秒。慢身份校验做完后,再补最后一轮端口、连接、进程树复检,卡住这个 2 秒证据时限,最后直接从内核复验目标的启动时间,确认无误立即发信号。
任何一个检查点发现 established 连接、发现连接数据源失败、发现身份或进程树变化——整棵进程树都不发信号。这不是「检查一次然后动手」,是「贴着执行的每一步反复检查,窗口一旦变脏就整体放弃」。
为什么要「直接从内核复验启动时间」
第 5 步末尾那个「发信号前从内核复验启动时间」值得单独说,它堵的是 TOCTOU 里最阴险的一种:PID 复用。
PID 是会被操作系统回收再分配的。假设诊断时 PID 4242 是你项目里那个该停的 Vite,就在你要发信号的前一刻,它自己退出了,而系统把 4242 分给了一个刚起来的、完全无关的进程——甚至可能是个要紧的东西。这时候 kill(4242, SIGTERM) 打到的是无辜者。
防线是:进程的真正身份不是 PID,而是 (PID, 稳定启动时间) 这个二元组。启动时间由内核记录、不可伪造、进程一生不变。所以在发信号的最后一瞬,直接问内核「4242 现在的启动时间还是不是我当初记的那个」——一致才发,不一致说明 PID 已经易主,立即放弃。把这个复验放在尽可能贴近 kill 的位置,就是为了让「复验通过」到「信号送达」之间的窗口小到几乎没有。
停止流程:叶子到根,如实记录
真正执行停止时,还有几条同样服务于「宁可漏报不可误杀」的纪律:
- 发信号前先持久化快照。停止前把恢复所需的信息落盘(下一篇细讲),快照写失败就零信号、直接报错——不留「停了但没法恢复」的状态;
- 按叶子到根发 SIGTERM。先停子进程再停父进程,避免父进程被停后重新拉起或留下僵尸;
- 如实记录五种结果——已停止、仍在运行、已退出、已跳过、失败。尤其是:动作之前就已经自己退出的节点,计入「跳过」,绝不冒充「已停止」。谎报一个「已停止」,用户就会以为端口释放了、可以起新服务了,结果撞车;
- 停止后重新扫描。如果同一项目冒出新进程、或一个复用了原 PID 的替代进程、或命令相同但确认不了 cwd 的进程,结果标记为「部分停止」并禁用重启——宁可告诉用户「没完全停干净」,不可假装成功;
- 3.0 不自动升级到 SIGKILL。温和停止是默认且唯一的自动路径,SIGKILL 只留给用户显式的高级操作。
还有一条工程纪律:整个停止和后续重启都在后台任务执行,绝不阻塞菜单栏主线程;刷新用 generation / 排队机制,防止一个慢的旧扫描结果覆盖掉动作后的新状态。
复盘
- 检查和使用之间的窗口,就是安全的命门。任何「先判断安全、再执行动作」的设计,都要假设判断到执行之间世界会变——尤其当执行是 kill 这种不可逆动作。别信几秒前的快照;
- 把一次检查摊成贴着执行的连续检查点。用户确认后重新诊断、发信号前用两次网络检查夹住活动采样、整个流程卡一个 2 秒证据时限——窗口收得越小,脏状态溜进去的概率越低;
- 进程身份是 (PID, 启动时间),不是 PID。PID 会被复用,只认 PID 迟早会 kill 错对象。在最贴近信号的位置直接从内核复验启动时间,是防 PID 复用的最后一道闸;
- 如实记录,别谎报成功。已退出记成「跳过」而非「已停止」、没停干净标成「部分停止」——清理工具谎报一个成功,用户就会基于它做下一个动作,然后撞车。诚实的失败远胜乐观的假成功。
下一篇是这套流程的另一半:那个「发信号前持久化的快照」到底存了什么,以及怎么在不经 shell 的前提下,把停掉的服务安全地再启动回来。
留言