mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
## 症状
本机积累 **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 还原。教训:变异测试必须逐次收尾,别靠记忆。
78 lines
2.6 KiB
Go
78 lines
2.6 KiB
Go
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
|
||
}
|