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>
157 lines
4.8 KiB
Go
157 lines
4.8 KiB
Go
package proc
|
||
|
||
import (
|
||
"bufio"
|
||
"context"
|
||
"io"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// newHeadlessProcess 构造一个**不 spawn 真进程**的 Process,供状态机单测使用。
|
||
//
|
||
// stdin 接到 io.Discard:abandon 会真的发 call.cancel 通知,
|
||
// 没有写端就会 nil 解引用。这样测试仍然走**真实代码路径**
|
||
// (包括发通知这一步),而不是把它 stub 掉——后者会让"取消到底有没有发出去"
|
||
// 变得无法被覆盖。
|
||
func newHeadlessProcess(name string) *Process {
|
||
return &Process{
|
||
name: name,
|
||
stdin: bufio.NewWriter(io.Discard),
|
||
inflight: make(map[uint64]*call),
|
||
exited: make(chan struct{}),
|
||
}
|
||
}
|
||
|
||
// 取消超时必须:① 强制回收该调用名下的扩容块;② 上报插件失去响应。
|
||
//
|
||
// 判据要区分两类超时(这是本设计的核心,2026-10-25 用户裁定):
|
||
//
|
||
// 业务超时 = 任务跑太久 → 正常,不作为不稳定依据
|
||
// 取消超时 = 喊停之后还不停 → 异常,是插件卡死的直接证据
|
||
//
|
||
// 变异:删掉 expireCanceled 里的 ReclaimRefs 调用 ⇒ 块泄漏判据变红。
|
||
// 变异:不调 onCancelIgnored ⇒ 上报判据变红。
|
||
func TestProcess_CancelIgnoredReclaimsBlocksAndReports(t *testing.T) {
|
||
host, err := NewHost()
|
||
if err != nil {
|
||
t.Fatalf("NewHost: %v", err)
|
||
}
|
||
defer host.Close()
|
||
|
||
var (
|
||
reportedCalls atomic.Uint64
|
||
reclaimed atomic.Int64
|
||
)
|
||
p := newHeadlessProcess("probe")
|
||
p.SetReclaimBlocks(func(refs []SharedRef) int {
|
||
n := host.ReclaimRefs(refs)
|
||
reclaimed.Add(int64(n))
|
||
return n
|
||
})
|
||
p.OnCancelIgnored(func(callID uint64, grace time.Duration) {
|
||
reportedCalls.Add(1)
|
||
})
|
||
|
||
// 造一次"插件申请了扩容块、随后被取消"的调用。
|
||
gen := host.Generation()
|
||
ref, err := host.Arena().Alloc(42, 4096, gen) // owner 42 = 插件
|
||
if err != nil {
|
||
t.Fatalf("Alloc: %v", err)
|
||
}
|
||
c := newCall(7, "tool.invoke")
|
||
c.trackBlock(ref)
|
||
p.registerCallForTest(c)
|
||
|
||
if used, _ := host.Arena().Stats(); used == 0 {
|
||
t.Fatal("前置:块应已占用")
|
||
}
|
||
|
||
p.abandon(c, context.Canceled)
|
||
|
||
// 宽限期未到:不应回收(插件可能还在收尾)。
|
||
if reclaimed.Load() != 0 {
|
||
t.Fatal("宽限期内不应强制回收")
|
||
}
|
||
|
||
// 模拟宽限期到期。
|
||
p.expireCanceled(c)
|
||
|
||
// ① 块必须被强制回收 —— 否则"取消了却瞒下内存",无日志只涨不落。
|
||
if used, _ := host.Arena().Stats(); used != 0 {
|
||
t.Fatalf("取消超时后 arena 应归零,实际 used=%d(块泄漏)", used)
|
||
}
|
||
if reclaimed.Load() == 0 {
|
||
t.Fatal("应调用强制回收")
|
||
}
|
||
|
||
// ② 必须上报(上层据此判定不稳定并重启)。
|
||
if reportedCalls.Load() != 1 {
|
||
t.Fatalf("应上报 1 次取消无响应,实际 %d", reportedCalls.Load())
|
||
}
|
||
|
||
// ③ 状态收敛到 failed(这次调用确实没正常完成)。
|
||
if c.State() != callFailed {
|
||
t.Fatalf("状态应为 failed,实际 %s", c.State())
|
||
}
|
||
}
|
||
|
||
// 插件在宽限期内停手(回了应答)→ 不上报,且连续计数应被清零。
|
||
func TestProcess_CancelHonoredDoesNotReport(t *testing.T) {
|
||
var reported, honored atomic.Int64
|
||
p := newHeadlessProcess("probe")
|
||
p.OnCancelIgnored(func(uint64, time.Duration) { reported.Add(1) })
|
||
p.OnCancelHonored(func(uint64) { honored.Add(1) })
|
||
|
||
c := newCall(11, "tool.invoke")
|
||
p.registerCallForTest(c)
|
||
p.abandon(c, context.Canceled)
|
||
|
||
// 插件随后回了应答(说明它收到 cancel 并停手了)。
|
||
if !p.deliverResponseForTest(&Response{ID: c.id, Result: []byte(`"x"`)}) {
|
||
t.Fatal("取消后的迟到应答应被认领")
|
||
}
|
||
|
||
if honored.Load() != 1 {
|
||
t.Fatalf("应记为已响应 1 次,实际 %d", honored.Load())
|
||
}
|
||
|
||
// 宽限期后再触发:已收敛,不应再上报。
|
||
p.expireCanceled(c)
|
||
if reported.Load() != 0 {
|
||
t.Fatal("已响应的调用不应被上报为无响应")
|
||
}
|
||
}
|
||
|
||
// 连续两次取消无响应才重启;中间成功响应一次即清零。
|
||
//
|
||
// 这是用户明确要求的判据:"注意是 cancel 态超时,超过两次说明插件本身
|
||
// 处于崩溃/不稳定态,自动重启对应插件"。
|
||
func TestPlugin_CancelIgnoredThreshold(t *testing.T) {
|
||
p := &Plugin{name: "flaky"}
|
||
var restarts atomic.Int64
|
||
p.onCrash = func(name string, err error) { restarts.Add(1) }
|
||
|
||
// 第 1 次:不重启(可能是收尾慢)。
|
||
p.noteCancelIgnored(1, cancelGrace)
|
||
if restarts.Load() != 0 {
|
||
t.Fatal("第 1 次取消无响应不应重启")
|
||
}
|
||
|
||
// 第 2 次:达到阈值,重启。
|
||
p.noteCancelIgnored(2, cancelGrace)
|
||
if restarts.Load() != 1 {
|
||
t.Fatalf("连续第 %d 次应触发重启,实际 %d", cancelIgnoredLimit, restarts.Load())
|
||
}
|
||
|
||
// 计数清零后重新开始("连续"而非"累计")。
|
||
p.cancelIgnoredMu.Lock()
|
||
p.cancelIgnored = 0
|
||
p.cancelIgnoredMu.Unlock()
|
||
p.noteCancelIgnored(3, cancelGrace)
|
||
if restarts.Load() != 1 {
|
||
t.Fatal("清零后第一次不应再次重启(判据是连续)")
|
||
}
|
||
}
|