fix(kernel): Close() 从不调 on_unload —— 孤儿进程与回调文件泄漏

## 症状
本机积累 **995 个 llmsproxy-loopback.py 进程**(828 个超 24 小时),全是
/tmp/Test*/ 与 .build-work/Test*/ 下的测试残留;生产 /etc/llmsproxy/plugins/
另有 **18 个孤儿 callback-*.port**(对应 .json 早已消失),且永远清不掉。

## 真因(两层)
1. **回环回收本是惰性的**:reapLoopback 只在**会话过期**时被 sweepSessions
   触发。测试跑完直接退出 ⇒ 会话从未「过期」⇒ 进程常驻。生产会话真会过期,
   所以生产不漏进程 —— 但见第 2 条。
2. **★ 根因在内核**:Plugins.Close() 只关 shard 与 saver,**从不调用
   on_unload** —— 而 on_unload 只在**单插件 Unload** 时调。于是「进程关闭/
   测试结束」这条路径上,起了 OS 进程的插件永远收不了尾。插件的
   plugin.on_unload(回收浏览器栈 + 回环)写得完全正确,只是从不被调用。
   这是「两处只修一处」缺陷的又一例:Unload 有、Close 没有。

## 修法
- 抽 invokeOnUnload(p, name) 助手,**Unload 与 Close 共用同一份实现**
  (两处各写一遍必然漂移,本次缺陷形状正是如此);Close() 新增
  callOnUnloadAll()。
- ★ 顺序:shards.Shutdown() → save.Close()(落盘)→ callOnUnloadAll()。
  落盘必须在前 —— saver 的最终写入要读插件的 Lua 表,on_unload 之后 state
  已关,读到的就是空,最后一个区间的累积静默丢失(TestCloseFlushesTail 正是
  为守住这一点而存在)。我第一版把顺序写反,被该测试当场咬住。
- channels:启动清扫上一进程留下的孤儿 callback-*.port/.json。loopbacks
  追踪表只活在内存,进程被强杀时 on_unload 没机会跑,重启后内存表为空 ⇒
  reapLoopback 无从知晓 ⇒ 那 18 个文件永久残留。
- channels:补 reap_loopbacks 端点(生产运维也可用:不必等会话过期)。
- channels:__set_loopback_probe 从**覆盖**改为**合并**条目 —— 覆盖会丢掉
  真实会话的 pid/port_file,让 reapLoopback 杀不掉接收器,**探针自己成为
  泄漏源**(实测:其他测试已归零,只有它留 1 个孤儿)。

## 判据(均做变异验证)
- internal/lua/plugin_unload_on_close_test.go
    TestPluginsCloseCallsOnUnload      看真实外部效应(插件在 on_unload 写磁盘
                                       marker),而非「钩子被调用过」这类内部信号
    TestPluginsCloseSurvivesFailingOnUnload  best-effort 契约
- internal/core/channels_orphan_callback_test.go
    TestPluginLoadSweepsOrphanCallbackFiles  看目录真实状态:孤儿必须消失,
                                       非回调文件(.channels-store.json)不得误删
- 变异均咬住:注释掉 callOnUnloadAll → 报红;清扫改 no-op → 报红。

## 实测
修复前跑一次起会话的测试批次留 7×N 个残留进程 → 修复后 **0 个**;全量回归
(go test -tags luajit ./... 10 包)后回环/VNC 残留均为 0。

## 过程中自查出的两处疏漏(已修)
1. 判据顺序又写反一次(同上 TestCloseFlushesTail 咬住)。
2. **两枚变异残渣留在工作区**未还原:internal/gateway/discover.go 里
   `add(raw, "/models", 0)` 被删、`classifyAuthStatus` 的 `|| len(body) >= 0`
   恒真注入。已 git checkout 还原。教训:变异测试必须逐次收尾,别靠记忆。
This commit is contained in:
JianFeeeee
2026-10-15 22:43:08 +08:00
parent 8d23005f7d
commit 89d759ece7
4 changed files with 271 additions and 21 deletions

View File

@ -572,10 +572,72 @@ func (ps *Plugins) Close() {
if ps == nil {
return
}
// ★ 顺序至关重要:**先落盘,再给插件收尾**。
//
// saver 的最终写入要读每个插件的 Lua 表;on_unload 之后 state 已关,
// 读到的就是空 —— 最后一个区间的累积静默丢失(persist_test 的
// TestCloseFlushesTail 正是为守住这一点而存在)。
//
// 所以不能把 callOnUnloadAll 挪到前面,也不能把 save.Close 挪到后面。
// Shard VMs are separate golua states; leaving them open leaks a VM per
// shard on every reload.
ps.shards.Shutdown()
ps.save.Close()
ps.callOnUnloadAll()
}
// callOnUnloadAll 依次给每个插件调 on_unload(best-effort)。
//
// 与 Unload 里的调用共用同一实现:两处若各写一遍,将来必然漂移成一个修了
// 一个没修 —— 这正是本次缺陷的形状(Unload 有、Close 没有)。
func (ps *Plugins) callOnUnloadAll() {
ps.mu.Lock()
plugins := make([]*Plugin, len(ps.plugins))
copy(plugins, ps.plugins)
ps.mu.Unlock()
for _, p := range plugins {
ps.callOnUnload(p)
}
}
// callOnUnload 调单个插件的 on_unload(存在才调),并关掉它的 Lua state。
//
// 为什么关 state 也在这里:on_unload 之后 state 就没用了,而 state 一旦关掉,
// 钩子再也调不了 —— 调用与关闭必须紧邻,否则又会漏。Unload 与 Close 各自持有
// 的 state 生命周期不同(Unload 还要 rebuild,Close 之后整体销毁),所以这里
// 只做「调用 + 置空」,由调用方决定其余步骤。
func (ps *Plugins) callOnUnload(p *Plugin) {
if p == nil {
return
}
if err := ps.invokeOnUnload(p, p.Info.Name); err != nil {
log.Printf("[plugins] %s: on_unload: %v", p.Info.Name, err)
}
}
// invokeOnUnload 真正调钩子。抽出来是为了让 Unload 与 Close 共用一份实现
// (含 state 判空与错误返回语义),避免两处契约不一致。
func (ps *Plugins) invokeOnUnload(p *Plugin, name string) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.state == nil || p.state.L == nil {
return nil
}
L := p.state.L
L.GetGlobal(pluginGlobal)
if L.Type(-1) != golua.LUA_TTABLE {
L.Pop(1)
return nil
}
L.GetField(-1, "on_unload")
var err error
if L.Type(-1) == golua.LUA_TFUNCTION {
err = callFnOn(L, "on_unload")
}
L.Pop(2) // plugin table + field remnant
p.state.L.Close()
p.state = nil
return err
}
// LoadDir loads every .lua file in dir as a plugin. Files are loaded in
@ -1941,24 +2003,10 @@ func (ps *Plugins) Unload(name string) error {
// only guaranteed point where it still can. Best-effort by design — a
// hook that errors must not block an unload the operator explicitly asked
// for (a wedged plugin has its own path above).
p.mu.Lock()
if p.state != nil && p.state.L != nil {
p.state.L.GetGlobal(pluginGlobal)
if L := p.state.L; L.Type(-1) == golua.LUA_TTABLE {
L.GetField(-1, "on_unload")
if L.Type(-1) == golua.LUA_TFUNCTION {
if err := callFnOn(L, "on_unload"); err != nil {
log.Printf("[plugins] %s: on_unload: %v", name, err)
}
}
L.Pop(2) // adapter/plugin table + field remnant
} else {
L.Pop(1)
}
p.state.L.Close()
p.state = nil
}
p.mu.Unlock()
//
// 与 Close 共用 invokeOnUnload:两处各写一遍必然漂移 —— 本次缺陷正是
// 「Unload 有、Close 没有」。
_ = ps.invokeOnUnload(p, name)
ps.rebuild()
return nil
}