# ADR-0008:多个 DSH 实例共享一个安装根时的接管策略 > **这个文件解决什么问题**:一台机器上同时跑两个以上 DSH 实例(都用本插件)时,后台服务会被反复 SIGTERM 杀掉再拉起 —— 每次都是"别人的子进程还没应答健康检查"。这里记录为什么共享安装根是正常用法、旧行为为什么会互杀、以及我们只修哪一半、故意不修哪一半。 - **Status**:Accepted - **Date**:2026-09-25 - **相关**:[ADR-0002](0002-install-directory.md)(安装目录)、[ADR-0004](0004-binary-upgrade.md)(二进制升级)、[`docs/verify-mcp-inputschema-fix.md`](../verify-mcp-inputschema-fix.md)(同期排查记录) --- ## Context 安装根是**机器级**的:`~/.dsh/dsh-feyagate/` 里存 `state.json`、`server.pid`、`versions/`、`cache/`、`data/`(平台凭据、授权、记忆)。它不是按 profile 或按 DSH 进程隔离的 —— 这是刻意的: - 后台服务是**机器级资源**(一个进程、一个端口、一份平台登录态); - 用户在一台机器上开多个 DSH 实例(Web GUI + 额外实例)是常见用法; - 每个实例都装一份 30MB 二进制、各自登录一遍平台账号,是明显更差的体验。 但"共享"和"进程与 pid 文件是单份"叠在一起,就出现了一个真实故障。 **实测**(2026-09-25,本机三个实例 `15008` / `18185` / `92081` 跑同一个 profile): - 插件日志 18:16–18:20 记录 **8 轮** `后台服务异常退出(code 0,signal -)` + `2000/5000 ms 后自动重启`; - 子进程自己的日志给出原因:`Received signal 15, shutting down...` —— **被外部 SIGTERM**; - 旧 `tryAdopt()` 的规则是:"pid 文件里的进程活着但 `/health` 不通 ⇒ 杀掉它、起我们自己的"。多个实例在启动窗口内彼此观察时,每个实例都把**别人正在绑定端口的子进程**判为"无响应",于是依次杀掉再各自拉起 → 互杀循环。 关键细节:这不是"谁错了",而是**单实例假设**在多实例下失效 —— 旧规则在只有一个实例时完全正确。 ## Decision **只改接管时机,不改所有权模型**: 1. `tryAdopt()` 遇到"pid 活着但 `/health` 尚未应答"时,先按记录里的 `startedAt` 计算**启动宽限期**(`ADOPT_GRACE_MS = 30_000`,轮询 `ADOPT_POLL_MS = 500`)。宽限期内**不杀**,改为轮询;一旦健康就**接管**(这正是想要的结局:所有实例共用同一个子进程)。宽限期用尽仍不健康,才回到旧行为(清理 pid 文件后自己拉起)。 2. 宽限期是**基于时间**的(启动窗口)。启动窗口的判定不引入新协议。 3. **属主模型**(同一天补上,因为看门狗还留着第二个互杀入口):`server.pid` 增加 `ownerPid` —— **启动这个进程的 DSH 实例**。看门狗只允许重启"属于自己"或"属主已 经不在"的进程;别人的子进程卡死时只**如实报告**(`后台服务无响应,但它属于另一个 仍在运行的 DSH 实例`),不杀、也不自己再拉一个。接管时: - 属主仍活着 ⇒ 我们只做**访客**(不篡改 pid 文件); - 属主已退出、或 pid 文件是旧版写的(没有 `ownerPid`)⇒ 我们在接管的同一刻 **把自己登记为属主**,于是"卡死就重启"这条自愈能力落在活着的实例身上。 这条规则的不变式:**任何一个时刻,只有一个实例有权替换当前的子进程**。 ## Consequences **好的**: - 并发启动不再互杀:竞态里"晚一步"的实例会**等到**赢家的子进程健康,然后接管它(共享一个进程,端口与登录态都只有一份)。 - 单实例行为不变:正常退出后重启 DSH,子进程健康 ⇒ 仍是立即接管(不引入任何等待)。 - 真正的卡死仍会自愈:宽限期过后依旧不健康 ⇒ 清理 + 自起(与旧行为一致)。 **坏的 / 仍未解决**: - ~~看门狗仍会对"接管来的"子进程发 SIGTERM~~ **已解决**:见 Decision 第 3 条,属主模型 + `scripts/check-supervise-ownership.mjs`(10 项断言,含"访客不杀"与"属主消失后才替换"两个方向)。 - 多个实例会互相覆盖 `state.json` 里的 `effectivePort`(字段是单份)。已缓解:门面代理的目标改为**优先用它自己 supervisor 的内存端口**,`state.json` 只作兜底,所以"另一个实例写了个别的端口"不会再让我们代理错地方。状态的**显示**仍可能有短暂滞后,未观察到功能影响。 - 在任一实例点「停止服务」会停掉**共享的**那一个(其它实例的会话随之失去工具)。这是用户的显式操作,保留"停就是停"的语义;界面文案需要说明它是机器级的。 - 我们**不**做"单实例互斥":用户可能故意多开,互斥会让第二个实例直接没有工具。 - 建议(写进 README 故障排查):日常只保留**一个** DSH 实例使用本插件。 ## Alternatives rejected 1. **每个实例独立安装根**(按 profile 或 pid 隔离):每个实例各装一份二进制、各占一个端口、各自登录平台账号 —— 把共享故障换成"用户要重复登录、授权要重复激活",且 30MB × N 的磁盘浪费。**否掉**。 2. **文件锁做单实例互斥**:第二个实例拿不到锁 ⇒ 它的会话里**一个工具都没有**(正是本次要修的现象)。**否掉**。 3. **pid 文件里记 `ownerPid`,按属主判定"该不该杀"**:**已采纳**(Decision 第 3 条)。当时列为后续是等真实案例,结果第一个案例就是本机的看门狗路径;成本确认很低:旧 pid 文件缺 `ownerPid` 时按"无人拥有"处理,正好等于旧行为,兼容是自然的。 4. **把宽限期做成可配置项**:多一个用户不可能理解的旋钮。用常量(`constants.ts`)。**例外**:看门狗的 `watchdogIntervalMs` / `watchdogFailures` 做成了插件配置项 —— 不是给用户调的,而是让测试能把 45 秒压到 3 秒(`scripts/check-supervise-ownership.mjs`)。宽限期本身仍是常量。