「尝试重新启动」不是「撤销」——不经 shell 的重启与崩溃恢复账本
Pier 3.0 复盘:停掉一个 dev server 后想再启动回来,看着简单,坑很深。为什么按钮叫「尝试重新启动」而不是「撤销」、为什么重启绝不能过 shell、以及一个能容忍自身崩溃和账本损坏的恢复设计。
Pier 3.0 停掉一个开发残留后,会提供一个「尝试重新启动」。听起来是个小便利功能——把刚才 kill 的命令再跑一遍嘛。真做起来,它是整个清理流程里坑最深的一块:涉及进程命令的忠实捕获、不经 shell 的安全执行、以及一套能容忍「Pier 自己崩了」和「恢复账本损坏了」的持久化设计。
为什么叫「尝试重新启动」,不叫「撤销」
先定性。这个按钮英文叫 Try restart、中文叫「尝试重新启动」,刻意不叫「撤销」。
因为它给不了「撤销」这个词隐含的承诺。原来那个进程可能依赖一堆 Pier 抓不到、也重建不了的东西:shell 里 export 的环境变量、一次性的临时 token、父进程传下来的状态、登录会话上下文、启动时生成的临时文件。Pier 能保存的只有命令和 cwd,它没法保证完美还原。
把它叫「撤销」,是在给用户一个会违约的承诺;叫「尝试重新启动」,是诚实地说明「我会尽力用同样的命令再起一个,但不担保和原来一模一样」。产品诚实先于功能漂亮——一个名不副实的「撤销」按钮,第一次失灵就摧毁信任。
重启绝不能过 shell
最关键的一条技术决定:重启不经过 shell 解释。
这件事的坑在于「命令」有两种形态。你在 ps 里看到的是一个空格拼起来的字符串,比如 node /path/to server.js --port 3000。如果为了重启,把这个字符串丢给 sh -c 去跑,就等于把它重新交给 shell 解释一遍——而里面任何一个空格、引号、;、$() 都会被 shell 当成语法。一个路径里带空格的项目、一个参数里带分号的命令,轻则起不来,重则执行了你没预期的东西。对一个自动重启功能,这是不可接受的攻击面。
正解是绕开 shell,从内核拿真实的 argv 边界:
- 从
KERN_PROCARGS2捕获进程真正的 argv 数组——内核记录的参数边界,不是猜的。快照同时保存两份:一份用于展示的命令字符串,一份用于执行的 argv 数组; - 重启时把 argv 解析成「绝对可执行路径 + 参数数组」,交给
Process直接启动。这样参数里的空格、引号、;全都只是普通字面量,不会被任何 shell 解释; - 拒绝几类危险输入:缺失或损坏的 argv、裸的环境变量赋值(
FOO=bar打头)、以及直接或用env包起来的sh/bash/zsh/fish解释器——不给「重启一个 shell」这条路。
环境变量也一并收严:显式的 PATH 是权威值,不偷偷追加 Homebrew 或 nvm 路径;只有在原进程根本没有显式 PATH 时,才用捕获路径加 Homebrew、常见 nvm 目录去补全一个 GUI App 的精简环境。
防重复实例:启动前再查一次
「尝试重新启动」还有个隐患——起出重复实例。用户可能手动已经把服务起回来了,Pier 再起一个就是双开。
所以启动前再查两件事:原来的端口是不是已经被占了、有没有一个 cwd 和归一化 argv 都相同的等价进程已经在跑。任一命中就不启动。而且这里也 fail closed:如果连那个疑似同类进程的 argv 都拿不到、没法确认它是不是同一个,宁可不启动,也不冒双开的险。
崩溃恢复账本:容忍 Pier 自己挂掉
最硬核的一块:如果 Pier 在「已经发起重启、但还没确认结果」的当口自己崩了、或被强退了,怎么办?
这靠一个持久化的恢复账本加一把跨进程锁。流程是:
- 点击重启后,先在跨进程项目锁内把状态持久化成
restartInProgress,再去做冲突检查和启动; - 启动结束后,才把状态改写成
restartAttempted(已发起)或restartFailed(失败)。
这个顺序是关键。先写 restartInProgress 再启动,意味着:哪怕 Pier 在 Process.run() 那一瞬崩溃,账本里也已经留下「这次重启可能已经把目标起来了」的记录。App 重启后读到 restartInProgress,就知道上次尝试可能已经启动了目标——于是只显示一个「未确认」状态,绝不自动重试。因为自动重试 = 可能双开,而「可能已经起来了」的情况下双开,正是要避免的。
那把锁按标准化后的项目 cwd 串行化同一项目的所有重启,而不是按快照的 UUID。这样既防止「同义但不同写法的命令」并发,也防止「同项目、不同命令」并发地各起一个,造成重复服务。完整 argv 另外作为二阶段的目标身份校验。
账本损坏也要 fail closed
账本本身也可能出问题——磁盘写坏、格式错乱。这里的规则和整个 3.0 一致:
- 账本文件不存在,视为空历史,正常;
- 文件存在却读不出、解不了码,必须失败关闭,并保留原始字节——不能把一个读不懂的账本当成空的,那可能丢掉「有次重启还没确认」的关键记录;
- 一条损坏的账本条目要被隔离,不能因为其中一条坏了,整本账本连同其它完好记录一起报废。
还有几条守住「别声称过头」的红线:
- 老版本那些没有已验证
restartCommands的快照,只能作审计记录,不能凭它合成一条命令去重启; Process.run()成功,只代表发起了尝试,不代表服务真起来了。界面必须显示成黄色的「已尝试」,不能显示成绿色的「已恢复」;- 3.0 只自动重启恰好一个独立的根入口。多根启动没法保证原子性(起一半崩了怎么办),所以干脆不提供那个按钮。
复盘
- 给不了的承诺,名字里就别给。「撤销」隐含「回到从前」,而进程依赖的环境、token、临时状态往往重建不了;叫「尝试重新启动」,把不确定性写进按钮名,比事后解释为什么没撤销干净强;
- 重新执行命令,永远绕开 shell。
ps的命令是空格拼的字符串,重新交给 shell 会把空格、引号、;当语法;从内核拿真实 argv,用Process直启,让特殊字符只当字面量——这既是正确性,也是安全性; - 可能改变外部世界的操作,要能容忍自己中途死掉。先持久化「进行中」再动手,崩溃后靠账本恢复成「未确认」而非盲目重试;跨进程锁按稳定的项目身份串行化,防并发双开;
- 持久化状态的失败也要 fail closed。账本读不出就保留原始字节、别当空的;单条损坏就隔离、别废掉整本;发起 ≠ 成功,UI 别把「已尝试」画成「已恢复」。
这是 Pier 3.0 安全系列的收尾。四篇连起来是同一套哲学在不同层的展开:定位与安全哲学、fail-closed 的分级、对抗 TOCTOU 的安全停止,到这篇的安全重启与恢复——核心始终是那句「宁可漏报,不可误杀」,以及「不确定时,诚实地表达不确定」。
留言