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>
70 lines
2.5 KiB
Go
70 lines
2.5 KiB
Go
package proc
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// 回归判据:内核代插件回收扩容块时必须用**插件自己的 ownerID**。
|
||
//
|
||
// 病根(2026-10-25 线上):插件经 arena.alloc 申请扩容块时块头 owner 是
|
||
// 它自己的 ownerID,而内核三处归还全写着 Free(OwnerHost, ref)。归属校验
|
||
// 必然失败、错误又被 `_ =` 吞掉 ⇒ 每返回一次大结果就永久泄漏一块。
|
||
// 4MB 的 arena 被大工具结果(单次 64KB+ 的返回)慢慢吃满后,连内核标定的
|
||
// 64KB 调用帧都分不出来,所有插件工具一起报「arena: 空间不足」。
|
||
//
|
||
// 判据两半:
|
||
// 1. Free(OwnerHost, 插件块) 必须失败 —— 确认这条路径本来就是错的
|
||
// 2. Free(插件owner, 插件块) 必须成功且 Stats 归零 —— 确认修法真的回收
|
||
//
|
||
// 变异:把 invokeTool / invokeCleaner / invokeOutput 里任一处改回
|
||
// Free(OwnerHost, ...),E2E 判据立即变红。
|
||
func TestArena_ExpandBlockReclaimedWithPluginOwner(t *testing.T) {
|
||
a := newTestArena(t, 1<<20)
|
||
const pluginOwner = 7
|
||
|
||
ref, err := a.Alloc(pluginOwner, 100000, 1)
|
||
if err != nil {
|
||
t.Fatalf("Alloc: %v", err)
|
||
}
|
||
|
||
// ① 旧写法必须失败:这就是泄漏机制本身。
|
||
err = a.Free(OwnerHost, ref)
|
||
if err == nil {
|
||
t.Fatal("Free(OwnerHost, 插件块) 竟然成功了——归属校验失效,本判据失去意义")
|
||
}
|
||
if !strings.Contains(err.Error(), "不属于调用者") {
|
||
t.Fatalf("期望归属校验错误,实际: %v", err)
|
||
}
|
||
if used, _ := a.Stats(); used == 0 {
|
||
t.Fatal("旧路径失败后块却被回收了?")
|
||
}
|
||
|
||
// ② 正确写法:用插件自己的 ownerID 归还。
|
||
if err := a.Free(pluginOwner, ref); err != nil {
|
||
t.Fatalf("按插件 owner 归还应成功: %v", err)
|
||
}
|
||
if used, _ := a.Stats(); used != 0 {
|
||
t.Fatalf("回收后 arena 应归零,实际 used=%d", used)
|
||
}
|
||
}
|
||
|
||
// 扩容块归还后空间必须可复用:连做 30 轮「申请 200KB → 归还」,
|
||
// 总分配量远超 arena 容量,但 used 每轮都应回到 0。
|
||
func TestArena_ExpandBlocksDoNotAccumulate(t *testing.T) {
|
||
a := newTestArena(t, 1<<20)
|
||
const owner = 3
|
||
for i := 0; i < 30; i++ {
|
||
ref, err := a.Alloc(owner, 200*1024, 1)
|
||
if err != nil {
|
||
t.Fatalf("第 %d 轮分配失败(累计分配 %dKB 未回收?): %v", i, i*200, err)
|
||
}
|
||
if err := a.Free(owner, ref); err != nil {
|
||
t.Fatalf("第 %d 轮归还失败: %v", i, err)
|
||
}
|
||
if used, _ := a.Stats(); used != 0 {
|
||
t.Fatalf("第 %d 轮后 used=%d,块未回收", i, used)
|
||
}
|
||
}
|
||
}
|