mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
## 1. arena 泄漏(线上 arena 被打满的根因)
插件经 arena.alloc 申请的扩容块,块头 owner 记的是**插件的** ownerID,
而内核三处归还全写着 Free(OwnerHost, ref)。归属校验必然失败,错误又被
`_ =` 吞掉 ⇒ 每返回一次大结果就永久泄漏一块。
线上表现(2026-10-25):重任务跑几轮后 4MB arena 满,之后连 64KB 调用帧
都分不出来,所有插件工具一起报「arena: 空间不足(请求 65779 字节)」。
修法:三处改用插件自己的 ownerID,且失败必须留日志(吞掉错误等于把
下一次「空间不足」的原因提前销毁)。
## 2. 取消成为一等状态
旧实现里「调用在进行中」只表达为 pending 表里有这个键:取消时直接
delete,状态的痕迹全部消失,插件随后回的应答只能走「未知 id,丢弃」。
那是副作用的正确,不是规则的正确。
改成每次调用带显式状态机(callstate.go):
pending ──应答──→ done
├──取消/超时──→ canceled ──迟到应答──→ done
└──进程退出──→ failed
迁移表集中在 legalTransition,非法组合一律拒绝(CAS 只校验 from,
不校验 to——实测曾把 done 改回 failed)。
## 3. 取消跨进程送达
内核超时到点后只停止「等」,插件侧的 handler 还在跑,占着连接/子进程/
文件句柄,结果却已被丢弃。现在 CallContext 取消时向插件发 call.cancel
通知(fire-and-forget),插件运行时按 **RPC id** 定位并 cancel 对应调用的 ctx。
不按工具名定位:同一工具可并发多次,按名字会误伤其它实例。
插件作者零改动:handler 签名不变,取消由模板运行时接住。
## 4. 取消超时判定插件不稳定
严格区分两类超时(用户裁定):
业务超时 = 任务跑太久 → 正常,不作为不稳定依据
取消超时 = 喊停之后还不停 → 异常,是插件失去响应的直接证据
取消宽限(cancelGrace=5s)到期仍无应答 ⇒ ① 强制回收该调用名下的扩容块
(插件已静默退出,永远不会归还)② 计数 ③ 连续两次触发既有崩溃重启链。
插件一旦响应取消即清零;重启后也清零(判据是"连续"不是"累计")。
判据:8 条新增单测 + 全部变异验证(不回收/不上报/不清零/取消不跨进程
任一处改坏,对应用例立刻变红)。
Co-Authored-By: Claude <noreply@anthropic.com>
75 lines
2.1 KiB
Go
75 lines
2.1 KiB
Go
package proc
|
||
|
||
import "testing"
|
||
|
||
const arenaLeakPluginSource = `package main
|
||
|
||
import (
|
||
"strings"
|
||
|
||
sdk "github.com/JianFeeeee/hologramagentsdk/sdk"
|
||
)
|
||
|
||
type leakPlugin struct{ name string }
|
||
|
||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||
return &leakPlugin{name: name}, nil
|
||
}
|
||
|
||
func (p *leakPlugin) Name() string { return p.name }
|
||
|
||
func (p *leakPlugin) Stop() error { return nil }
|
||
|
||
func (p *leakPlugin) Start(s *sdk.PluginSDK) error {
|
||
// 返回 200KB:远超内核标定的 64KB 结果预算,必然走 arenaPut 扩容块路径
|
||
// (模板里 frameOutput 放不下 → arenaAlloc → 打 sharedRefFlagExpand)。
|
||
s.RegisterTool("leak_big", sdk.ToolDef{
|
||
Description: "返回超大结果,触发扩容块",
|
||
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||
}, func(args map[string]interface{}) (interface{}, error) {
|
||
return strings.Repeat("x", 200*1024), nil
|
||
})
|
||
return nil
|
||
}
|
||
`
|
||
|
||
func TestE2E_ExpandBlocksReclaimed(t *testing.T) {
|
||
bin := buildPluginWithRealTemplate(t, arenaLeakPluginSource)
|
||
|
||
host, err := NewHost()
|
||
if err != nil {
|
||
t.Fatalf("NewHost: %v", err)
|
||
}
|
||
defer host.Close()
|
||
|
||
core := newFakeCore()
|
||
p := New("leak", bin, t.TempDir(), nil, host, nil)
|
||
if err := p.Start(core); err != nil {
|
||
t.Fatalf("Start: %v", err)
|
||
}
|
||
defer p.Close()
|
||
|
||
core.mu.Lock()
|
||
h, ok := core.tools["leak_big"]
|
||
core.mu.Unlock()
|
||
if !ok {
|
||
t.Fatal("插件应注册 leak_big 工具")
|
||
}
|
||
|
||
// 连调 10 次 ≈ 2MB 扩容块。arena 只有 4MB:旧实现每次泄漏 200KB,
|
||
// 第 ~19 次就耗尽;这里跑 10 次足以让旧实现在 used 上留下非零。
|
||
for i := 0; i < 10; i++ {
|
||
res, err := h(map[string]interface{}{})
|
||
if err != nil {
|
||
t.Fatalf("第 %d 次调用失败(arena 已被前面的泄漏吃掉?): %v", i, err)
|
||
}
|
||
s, _ := res.(string)
|
||
if len(s) != 200*1024 {
|
||
t.Fatalf("第 %d 次结果长度 %d,期望 %d", i, len(s), 200*1024)
|
||
}
|
||
if used, _ := host.Arena().Stats(); used != 0 {
|
||
t.Fatalf("第 %d 次调用后 arena used=%d,扩容块未归还(泄漏)", i, used)
|
||
}
|
||
}
|
||
}
|