Files
HomeAgent/internal/plugin/proc/cancel_ignored_test.go
JianFeeeee f5a1366770 fix(proc): arena 扩容块泄漏 + 取消成为一等状态
## 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>
2026-10-25 10:10:21 +08:00

157 lines
4.8 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 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("清零后第一次不应再次重启(判据是连续)")
}
}