Commit Graph

2 Commits

Author SHA1 Message Date
b4fb254bf5 fix(proc): 修 arena use-after-unmap 导致的内核 SIGSEGV(关停竞态)
## 症状

全量 go test 偶发 SIGSEGV,整个测试二进制被杀(recover 捕不到 runtime
致命错误)。崩溃栈(2026-09-25 实测捕获,完整):

    readLoop (process.go:334)
      → markExited → once.Do
        → onExit → Plugin.handleExit (plugin.go:209)
          → Host.ReclaimOwner (host.go:342)
            → arenaRegion.ReclaimOwner → blockBase → getU32
              → SIGSEGV  读已 munmap 的内存

## 根因(两个叠加缺陷,同一后果)

**① markExited 里 close(exited) 早于 onExit**

    close(p.exited)        // :394 —— 先关闭,唤醒所有等待者
    p.sup.untrack(p.name)
    p.onExit(...)          // :399 —— 回调里要读共享内存

onExit(内核侧 Plugin.handleExit)会调 Host.ReclaimOwner 回收该插件残留的
共享槽,那是要读共享内存区域的。而 exited 一关闭,Stop()/Kill() 就返回
(process.go:566/587),StopAll 随即返回,调用方(Host.Close)立刻
freeShm 解除映射 —— 此刻 onExit 还没跑完,ReclaimOwner 就成了读已 munmap
的内存。

修法:把 onExit 提到 close(p.exited) **之前**,并明确 exited 的语义是
「**完全**收尾完毕」而非「进程已死」——任何等待者看到它关闭后,都可安全
释放共享内存、卸载资源。

**② StopAll 超时分支 `go p.Kill()` 发射后不管**

    go p.Kill()   // 不等待

本函数返回后调用方就 unmap,而 Kill 内部要等 markExited 跑完(含 onExit)。
改为等全部 Kill 完成(Kill 自带 killReapTimeout 上限,不会无限拖住关停)。

**③ 同类的第三处:事件环订阅在关停时从不退订**

EventRing.Subscribe 注册到 Bus 的 handler 会 ring.WritePush(写共享内存),
而 Host.Close 会 munmap 整块区域。此前:
  - handleEvents 把 EvtRingSubscribe 的取消函数**直接丢弃**(corehandler_runtime.go:103)
  - EventsUnsubscribe 是 no-op,注释还写着「子进程 Stop 时由内核统一清理」,
    但 closeProcHost 根本没有退订
于是每个订阅过的插件都在 Bus 上永久留了一个写共享内存的 handler,
munmap 后任意一条事件经过 Publish 就会写已解除映射的内存 ⇒ 同类 SIGSEGV。

修法:EventRing 记为 unsubs、新增 EvtRingSubscribeTracked(订阅即登记),
Host.Close 在 freeShm **之前**调用 evtCloser.Close 统一退订。

## 回归测试(3 个,均经变异验证「修复前判红」)

1. `TestProcess_OnExitCompletesBeforeExitedCloses`(proc)
   断言 Exited() 关闭时 onExit 必须已返回。变异(把 close(exited) 挪回
   onExit 之前)→ FAIL。这是本次崩溃的直接判据。
2. `TestHost_CloseUnsubscribesEventRing`(proc)
   断言 Host.Close 调用了 evtCloser.Close。变异(撤掉退订)→ FAIL。
3. `TestEventRing_CloseUnsubscribesFromBus`(plugin)
   端到端:订阅 → Publish 有写入 → Close → Publish 不再写入。变异
   (Close 不做事)→ FAIL。

顺带给 EvtRing 加了 Written() 访问器(诊断 + 上述测试的可观察量)。

## 验证

- 三个新测试全绿;-race 下 ./internal/plugin/... 全绿
- proc 包连跑 12 轮、internal/plugins 连跑 20 轮:SIGSEGV 0 次
- 全量 go test -count=1 ./... → 38 ok / 0 FAIL
- go build ./... / go vet ./... 干净

## 另有两个**既有** flaky(本次未动,与 C 化无关,单独记录)

排查过程中用「我的树 20 轮 vs 干净树 20 轮」对照确认了归属:

- 测试间固定端口冲突(pluginmgr 9876 / remotedevice 9890 / webui 8080):
  并行或残留实例时报 bind: address already in use。干净树同样复现。
- TestRealPlugin_DeepSearchInvoke 依赖外部 SearXNG(127.0.0.1:8888):
  上游限流时(brave "too many requests"、duckduckgo/quark CAPTCHA)断言失败。
  干净树同样复现。属外部依赖,不是代码缺陷。

两者都不属本次「排查崩溃」的范围,已记入 plan.md 待办。
2026-09-25 17:25:43 +08:00
02cc74ce11 fix(proc): 子进程崩溃自愈 + 集中台账 + 注册面摘除
根因:子进程插件被 kill 后,内核只发了一个无人订阅的事件,
工具/stage handler/IO 通道全留在注册表里指向死进程,
模型继续调用只吃 ErrProcessExited,没有任何路径把插件拉回来。

## 四层修复

### 1. 专职 waitLoop(进程收割)
- 每个子进程配一根 waitLoop goroutine,是 cmd.Wait() 的唯一调用点
- 不再依赖 stdout EOF 判定死亡(孙子进程继承 stdout 时 EOF 永不到来)
- 手工 os.Pipe 替代 cmd.StdinPipe/StdoutPipe,避免 waitLoop 与
  os/exec 的内部关闭竞争
- host.go: Host.Supervisor(),Host.Close() 先 StopAll 再拆段

### 2. 集中台账 Supervisor
- proc/supervisor.go: 插件 Spawn 握手成功即 track,进程退出即 untrack
- StopAll: 并发发 plugin.stop 走优雅路径,到期仍在的一律 Kill
- 关停后才完成握手的进程被立即结束,不会活过内核
- 消除「孤儿进程持共享段映射 → SIGBUS」的隐患

### 3. 注册面摘除(detachPlugin)
- 新增 StageHost.UnregisterPluginStages:摘除指定插件的全部 stage handler
- 新增 Registry.pluginChannels 台账:记录每个插件注册的 IO 通道
- 三条路径统一走 detachPlugin:Disable / ReloadOne / RemovePlugin
- StopAndUnload 漏了 IO 通道也一并补上

### 4. 自动重启
- onProcCrash 从「只发事件」改为「摘注册面 → 从注册表移除 → 异步排重启」
- scheduleProcRestart: 窗口 5 分钟内最多 3 次,线性退避 1s/2s/3s
- 超限停手留日志;重启前复核是否已被 Disable 或被其他路径加载
- 崩溃计数窗口过期自动归零

### 5. 主动停止 vs 崩溃的区分
- proc.Plugin 新增 stopping 标志:Stop()/Close() 里 Set(true)
- handleExit 读 stopping 标志,主动停止不上报 onCrash
- 防止重载/禁用/卸载被误判为崩溃触发多余重启

### 6. Linux Pdeathsig 兜底
- procattr_linux.go: SysProcAttr.Pdeathsig = SIGKILL
- 兜 homed 自身被 SIGKILL/OOM 时子进程变孤儿的场景
- macOS/Windows 无等价物,空实现

### 7. pluginmgr 升级
- PluginManager 接口新增 PluginRuntime / ListPluginRuntimes
- plugin_list 输出运行态:loaded / alive / pid / crash_count / channel
- 新增 plugin_status: 全量运行期快照 + dead/unhealthy 汇总
- 新增 plugin_restart: 无条件重启单个插件(plgreload 不动未改二进制的插件)

### 测试
- process_test.go: 3 例(grandchild stdout 感知 / Supervisor track-untrack /
  StopAll 无孤儿)
- crash_recovery_test.go: 8 例(detach 三项齐全 / 通道重注册 / 崩溃不阻塞 /
  退避阈值 / 窗口过期 / 关停中跳过 / PluginRuntime 通道识别)
- stages_plugin_test.go: 4 例(stage 按插件摘除 / 空 stage 清理 / 空名 no-op /
  工具+stage 双摘后可重新注册同名)
2026-09-03 12:37:58 +08:00