/** * 引擎 **parent 监视**([ref] 建座,S-292 改判据)。 * * ## 防的是什么 * * 壳(cli/TUI/桌面)自 spawn 引擎的**同机形**里,壳被 SIGKILL(崩溃、`kill -9`、OOM killer)之后没有 * 任何清理钩子跑得起来:引擎被 reparent 到 init,**继续活着** —— 占端口、占库连接池、占模型配额,而且 * 再没有人会给它发 SIGTERM。`DB_BACKEND=local` 上还多一层:那只孤儿抱着 `LOCAL_DATA_ROOT/LOCK` * (core `FileStorageBackend` 的 pidfile,失效判定是 `process.kill(pid,0)` ⇒ **活孤儿是合法持锁人**), * 于是此后每一只新壳起自己的引擎都撞锁 fatal「another instance (pid N) owns this data dir」—— 用户看到 * 的是「壳崩过一次以后就再也起不来了」。持锁 pid 不存在时 core 已自动剪陈旧锁,缺的只有「父死、子活」 * 这一形。所以引擎给自己装一只**自查**腿:定期问一句"生我的那个还在吗"。 * * ## 本腿**不**做什么(S-306,硬 breaking) * * 它**不判死、不 drain、不退进程**。父没了只说明这台引擎成了**孤儿**,而「孤儿该不该退」是**租约** * 的问题:还有没有人附着、有没有在飞的 turn、静默够不够久 —— 那是 `boot/engine-lease.ts` 那一条腿, * 孤儿态只是把它的窗从 `ENGINE_LINGER_MS` 换成 `ENGINE_ORPHAN_LINGER_MS`(默认 2s)。 * 旧出口(父死**即** drain,单拍、零附着感知)在「A 起引擎、B 按 engine.port 复用、A 正常退出」这一形 * 下会在 B 的 turn 中间把引擎拆掉 —— 自退律从此**只有一条**,本腿退化成它的一个输入。 * * ## 机制(逐条都是契约) * * 1. **opt-in**:`SEMA_PARENT_PID` 缺席 ⇒ 整件不装配(`boot/shutdown.ts` 连 `createParentWatch` 都不调)。 * 存量部署逐字零变化 —— systemd / launchd / 容器托管的引擎本来就 reparent 到 1,自退是事故。 * 坏值(非正整数 / 超 int32)⇒ **启动期拒**,在 `config.ts` 的 `parentPidEnv()`。 * 2. **判据 = 两支合取口一句,任一成立即标孤儿**,每 {@link PARENT_WATCH_INTERVAL_MS} 一拍: * * · **强身份支 `reparented`**:`process.ppid` 不再是装配那一刻的那个值。生我的进程没了,内核就把 * 我过继给 init(或容器里的 subreaper)—— 这是**内核对亲子关系的直答**,不依赖 pid 的身份。 * 判据刻意**不写成 `ppid === 1`**:① 收养者未必是 1(PID 1 是 subreaper 的容器里不是); * ② 壳本身就是 pid 1 的容器形里(`SEMA_PARENT_PID=1`),`=== 1` 会在**开机第一拍**把一台健康 * 引擎标成孤儿。「不再是生我的那个」两种形都答得对,而且不需要任何特判。 * 🔴 **前提**(codex 复审 R1 [high],验真后修):这一支只有在「被监视的 pid 就是生我的那个」时 * 才等价于「它没了」。壳经一层 wrapper spawn 引擎却仍传**自己**的 pid 时,引擎的直接父是 * wrapper —— wrapper 先退会让这一支把一台**壳还活着**的健康引擎标成孤儿(于是它的租约窗被无故收短)。 * 前提在**装配那一刻**就能一次判完(`ppidAtArm === pid`),所以它是**装配期的开关**而不是每拍 * 的特判:不成立 ⇒ 强身份支整条不装,只留弱身份支(它在那一形下语义仍然正确),并且 armed 那行 * 改打 **warn** —— 覆盖面被降级了不许静默。 * · **弱身份支 `esrch`**:`process.kill(parentPid, 0)` 抛 `ESRCH`。0 号信号不投递任何信号,只做 * "这个 pid 存在吗 + 我能不能给它发信号"的检查。两种失败方向**语义相反**,写反了后果不对称: * - `ESRCH` = 没有这个进程 ⇒ 父**不在**(这是我们要抓的); * - `EPERM` = 进程**在**,只是本进程无权给它发信号(父跑在另一个 uid 下 —— systemd/su/容器里 * 很常见)⇒ **算活着**。把 EPERM 当"不在",每台这种机器上的引擎会在开机后一拍内自杀。 * * 强身份支**先判**:它成立时这一拍连探活都不发 —— 弱判据没有资格给一个已经被过继走的进程改口 * (僵尸父那一形里 `kill(pid,0)` 恰恰恒成功)。 * 3. **单拍即判,不去抖**。[ref] 定稿是「15s 一拍、连续两拍才判定」(上界 30s),S-292 整条退役: * 部署面的判据是「壳挂了以后数据根要在秒级重新可用」,而 `ESRCH` / `ppid` 都不是会抖的读数(两者 * 都是内核的直答,不是采样值)。去抖买不到确定性,只买到 15s 的延迟和一个 30s 长的 pid-复用窗口。 * 4. **命中一次即停探**(父不会复活),出口是 {@link ParentWatchDeps.onOrphaned} —— 装配层据此记下 * 「孤儿了 + 是哪一支判的」,租约腿下一拍读到它就换窗。本模块自己**从不** drain、**从不** * `process.exit`。已有停机属主(`isStopped`)时站下不重入(一次正在进行的优雅停机不必再被标孤儿)。 * 5. **interval 必须 unref**:监视腿不得成为"进程本该退出却退不掉"的理由(与 drain tick 刻意不 unref * 的取向相反 —— 那一只 tick 就是停机驱动,这一只只是观察者)。 * 6. **日志分档**([ref] 响亮律):装配 = `info`;标孤儿与探活自坏 = `warn`(非计划内的生命周期事件、 * 以及"这条腿此刻是失效的",都不许混在 info 流里)。档位是**闭集** {@link ParentWatchLogLevel},装配层用 * `Record` 派发 ⇒ 新增一档是编译错,不会静默落进 info。 * * ## 已知残余(成文,现版不收窄) * * · **装配前就死掉的父**:监视腿装在 `listen()` 之后,`ppidAtArm` 也是那一刻取的。父在 boot 途中就死 * **且**成了僵尸(它自己的上级不回收子进程)时,强身份支没有"变化"可看、弱身份支被僵尸的 pid 表项 * 骗住,两支都不触发。父只是普通地死掉(被回收)时弱身份支照样抓得到 —— 只有"boot 窗内 + 僵尸"这个 * 交集漏。要收口需要壳侧一条**持有型管道**(壳退出=管道 EOF),那是机制换代,归设计属主裁。 * · **中间包装进程**:壳经一层 wrapper spawn 引擎(壳传自己的 pid,而引擎的直接父是 wrapper)时,强身份 * 支整条不装(见上,装配期判定 + warn),于是这一形退回 [ref] 的老覆盖面:僵尸壳抓不到。约定仍是 * **壳直接 spawn 引擎**(`node dist/main.js`)—— 那一形两支都在。 */ /** 探活拍频。**固定值,不开 env 旋钮**(设计定稿:有真需求再议,防旋钮增殖)。 * 2s 的出处 = 部署面预算「壳挂掉后 ≤5s 数据根重新可用」减去一次 drain 的余量。 */ export declare const PARENT_WATCH_INTERVAL_MS = 2000; /** 日志档位闭集。装配层按 `Record` 派发 —— 新增一档必须在那里显式安置(编译错)。 */ export type ParentWatchLogLevel = "info" | "warn"; /** 成孤儿的缘由闭集(上 `parent_watch_orphaned` 的 `reason` 键,运维按它区分两支;也是租约自退留痕 * `exit-last.json` 与 `engine_lease_auto_exit` 事件里 `reason` 的孤儿那两个词)。 */ export type ParentOrphanReason = "reparented" | "esrch"; export interface ParentWatchDeps { /** 被监视的父 pid(`config.parentPid`,已在 config 层校验为正整数)。 */ pid: number; /** 探活 seam:抛错 = 探活失败(`.code` 携 errno)。缺省 = `process.kill(pid, 0)`。 * 存在的理由不是"可配置",是 `ESRCH`/`EPERM` 两支在测试里没有确定性的自然触发法。 */ probe?: (pid: number) => void; /** 亲子关系 seam:缺省 = `process.ppid`(**活读**,不是 boot 时的快照 —— 实测过继后当场变)。 * 存在的理由与 `probe` 相同:reparent 在进程内测试里没有确定性的触发法。 */ readPpid?: () => number; /** 已有停机属主(`closing || draining`)时监视腿站下:停机已经在走了,再标一次孤儿改变不了任何事, * 只会给一次计划内的停机添一条误导的 warn。 */ isStopped: () => boolean; /** * **唯一出口**(S-306):父不在了 ⇒ 把这台引擎标成孤儿。**至多调用一次**(命中即停探)。 * * 🔴 它**不是** drain:退不退由 `boot/engine-lease.ts` 的租约律按「无附着 ∧ 无在飞 ∧ 静默满 * `orphanLingerMs`」判。装配层拿到 reason 之后要做的只有一件事 —— 记下孤儿态(时刻 + 缘由)。 */ onOrphaned: (reason: ParentOrphanReason) => void; log: (level: ParentWatchLogLevel, event: string, fields: Record) => void; } /** 装配并**立刻**起监视(装配即 `parent_watch_armed`)。返回停机链用的 `stop()`。 */ export declare function createParentWatch(deps: ParentWatchDeps): { stop(): void; }; //# sourceMappingURL=parent-watch.d.ts.map