Files
ModelRouter/internal/lua/plugin_unload_on_close_test.go
JianFeeeee 89d759ece7 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 还原。教训:变异测试必须逐次收尾,别靠记忆。
2026-10-15 22:43:58 +08:00

78 lines
2.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package lua
import (
"os"
"path/filepath"
"testing"
)
// ★ 关闭 Plugins 时必须给插件一次 on_unload 机会。
//
// 真实缺陷:Close() 此前只关 shard 与 saver,**从不调用 on_unload** —— 而
// on_unload 只在单插件 Unload 时调。于是「进程关闭/测试结束」这条路径上,
// 起了 OS 进程的插件(回环回调接收器、浏览器栈)永远收不了尾,留下孤儿进程。
// 实测本机积累到 995 个 python 监听器(828 个超 24 小时),全是测试残留。
//
// 这个判据看的是**真实外部效应**(插件在 on_unload 里改磁盘上的文件),
// 而不是「钩子被调用过」这类内部信号 —— 后者在钩子写错名字时也会假绿。
func TestPluginsCloseCallsOnUnload(t *testing.T) {
dir := t.TempDir()
marker := filepath.Join(dir, "unloaded.marker")
pluginDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
src := `
local plugin = { name = "leaky", version = "1.0.0", exports = {} }
plugin.ui = nil
function plugin.on_unload()
local f = io.open("` + marker + `", "w")
if f then f:write("reaped"); f:close() end
end
return plugin
`
if err := os.WriteFile(filepath.Join(pluginDir, "leaky.lua"), []byte(src), 0644); err != nil {
t.Fatal(err)
}
ps := NewPlugins(nil, pluginDir)
if err := ps.LoadDir(); err != nil {
t.Fatalf("LoadDir: %v", err)
}
// 关之前不该有标记
if _, err := os.Stat(marker); err == nil {
t.Fatal("on_unload 在 Close 之前就被调了?")
}
ps.Close()
// ★ 真判据:文件必须被写出来(= on_unload 真的跑过)
if _, err := os.Stat(marker); err != nil {
t.Fatalf("Close() 没有调用 on_unload —— 起了 OS 进程的插件会留下孤儿进程"+
"(真实缺陷:995 个 python 监听器残留)。marker 不存在: %v", err)
}
t.Log("✓ Close() 触发了 on_unload(孤儿进程不会再堆积)")
}
// on_unload 抛错不得让 Close 崩掉(best-effort 契约)。
func TestPluginsCloseSurvivesFailingOnUnload(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
src := `
local plugin = { name = "boom", version = "1.0.0", exports = {} }
function plugin.on_unload() error("故意炸") end
return plugin
`
if err := os.WriteFile(filepath.Join(pluginDir, "boom.lua"), []byte(src), 0644); err != nil {
t.Fatal(err)
}
ps := NewPlugins(nil, pluginDir)
if err := ps.LoadDir(); err != nil {
t.Fatalf("LoadDir: %v", err)
}
ps.Close() // 不得 panic
}