mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +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>
184 lines
6.1 KiB
Go
184 lines
6.1 KiB
Go
package proc
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// ---- 单元:旁路 ctx 的存取与剥除 ----
|
||
|
||
// 旁路键必须**不泄漏**给插件,同时不影响真实参数。
|
||
func TestExecCtxArg_RoundtripAndStrip(t *testing.T) {
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
defer cancel()
|
||
|
||
args := map[string]interface{}{"path": "/tmp/a"}
|
||
bounded := map[string]interface{}{"path": "/tmp/a", execCtxArgKey: ctx}
|
||
|
||
if got := execCtxFromArgs(bounded); got != ctx {
|
||
t.Fatal("应取回绑定的 ctx")
|
||
}
|
||
got := stripExecCtx(bounded)
|
||
if _, leaked := got[execCtxArgKey]; leaked {
|
||
t.Fatal("旁路键必须从下发给插件的参数里剥掉(否则严格校验参数的插件会报未知参数)")
|
||
}
|
||
if got["path"] != "/tmp/a" {
|
||
t.Fatal("剥除不应影响真实参数")
|
||
}
|
||
|
||
// 没有绑定键:原样返回,不在热路径上多复制一次。
|
||
plain := stripExecCtx(args)
|
||
if len(plain) != 1 || plain["path"] != "/tmp/a" {
|
||
t.Fatal("无旁路键时应原样返回")
|
||
}
|
||
}
|
||
|
||
// 没有绑定时必须返回可用的 ctx(调用方无需判空)。
|
||
func TestExecCtxArg_DefaultsToBackground(t *testing.T) {
|
||
for _, args := range []map[string]interface{}{nil, {}, {"a": 1}} {
|
||
ctx := execCtxFromArgs(args)
|
||
if ctx == nil {
|
||
t.Fatal("必须返回非 nil ctx")
|
||
}
|
||
if ctx.Err() != nil {
|
||
t.Fatal("默认 ctx 不应处于取消态")
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- 端到端:取消真的送达插件 ----
|
||
|
||
// 内核超时后必须让**插件**停手,而不只是自己不再等。
|
||
//
|
||
// 判据:用真实 hmapdev 模板编译一个注册了长任务工具的插件,插件侧用
|
||
// ToolHandlerCtx 观察 ctx;内核取消后,插件必须很快观察到取消并回报。
|
||
//
|
||
// 变异:①去掉 Process.CallContext 里的 cancelCall(...) ⇒ 插件永远收不到取消,
|
||
// 本判据超时变红;②模板不处理 call.cancel ⇒ 同上。
|
||
func TestE2E_CancelReachesPlugin(t *testing.T) {
|
||
bin := buildPluginWithRealTemplate(t, cancelProbePluginSource)
|
||
|
||
host, err := NewHost()
|
||
if err != nil {
|
||
t.Fatalf("NewHost: %v", err)
|
||
}
|
||
defer host.Close()
|
||
|
||
core := newFakeCore()
|
||
plg := New("probe", bin, t.TempDir(), nil, host, nil)
|
||
if err := plg.Start(core); err != nil {
|
||
t.Fatalf("Start: %v", err)
|
||
}
|
||
defer plg.Close()
|
||
|
||
core.mu.Lock()
|
||
_, ok := core.tools["probe_slow"]
|
||
core.mu.Unlock()
|
||
if !ok {
|
||
t.Fatal("插件应注册 probe_slow 工具")
|
||
}
|
||
|
||
// 工具内部会睡 30 秒;内核 100ms 就放弃。
|
||
// 注意:h(...) 是注册到内核的 handler,它经 proc 桥打给子进程。
|
||
done := make(chan struct{})
|
||
go func() {
|
||
defer close(done)
|
||
// 直接打 invokeToolCtx 才能带 ctx;内核超时路径正是这么做的。
|
||
_, _ = plg.invokeToolCtx(newShortCtx(t, 100*time.Millisecond), "probe_slow", nil)
|
||
}()
|
||
|
||
select {
|
||
case <-done:
|
||
case <-time.After(5 * time.Second):
|
||
t.Fatal("取消后调用方应立即返回")
|
||
}
|
||
|
||
// 关键:插件侧必须观察到取消(它会把自己的观察结果写进 host 的一个
|
||
// 共享可读位置——这里用最直接的办法:再调一次工具的 "probe_state")。
|
||
// 简化:直接读插件进程的 stderr 不行,所以让工具在观察到取消时
|
||
// 把结果写进 arena,由测试读回。见 cancelProbePluginSource。
|
||
deadline := time.Now().Add(3 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
if state := readProbeState(t, plg); state == "canceled" {
|
||
return
|
||
}
|
||
time.Sleep(50 * time.Millisecond)
|
||
}
|
||
t.Fatal("插件没有观察到取消——取消未跨进程送达,handler 仍在空跑")
|
||
}
|
||
|
||
func newShortCtx(t *testing.T, d time.Duration) context.Context {
|
||
t.Helper()
|
||
ctx, cancel := context.WithTimeout(context.Background(), d)
|
||
t.Cleanup(cancel)
|
||
return ctx
|
||
}
|
||
|
||
// readProbeState 调 probe_state 工具读回插件侧的观察结果。
|
||
func readProbeState(t *testing.T, plg *Plugin) string {
|
||
t.Helper()
|
||
ctx := newShortCtx(t, 2*time.Second)
|
||
raw, err := plg.invokeToolCtx(ctx, "probe_state", nil)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
s, _ := raw.(string)
|
||
return strings.TrimSpace(s)
|
||
}
|
||
|
||
// 取消后插件**不得**再回正常应答(内核侧已是 canceled,回了只能被丢弃)。
|
||
//
|
||
// 判据方式:取消一次长任务,然后确认内核侧没有留下"迟到的成功应答"痕迹。
|
||
// 具体看两点:
|
||
// 1. 调用方拿到的是 ctx.Err(),不是插件的结果;
|
||
// 2. 插件进程没有因为重复应答而触发协议错误日志(用 plugin_state 探针
|
||
// 无法观测,故这里以 inflight 状态收敛为准)。
|
||
func TestE2E_CancelSuppressesPluginReply(t *testing.T) {
|
||
bin := buildPluginWithRealTemplate(t, cancelProbePluginSource)
|
||
|
||
host, err := NewHost()
|
||
if err != nil {
|
||
t.Fatalf("NewHost: %v", err)
|
||
}
|
||
defer host.Close()
|
||
|
||
core := newFakeCore()
|
||
plg := New("noreply", bin, t.TempDir(), nil, host, nil)
|
||
if err := plg.Start(core); err != nil {
|
||
t.Fatalf("Start: %v", err)
|
||
}
|
||
defer plg.Close()
|
||
|
||
// ★ 必须用**旧签名**的 probe_block:Ctx 版 handler 会因 ctx 取消而自己
|
||
// 提前返回,绕过"跑完后检查是否已被取消"那段代码,测不到它。
|
||
// probe_block 睡 300ms 才返回,此时内核早已取消。
|
||
ctx := newShortCtx(t, 100*time.Millisecond)
|
||
start := time.Now()
|
||
_, err = plg.invokeToolCtx(ctx, "probe_block", nil)
|
||
if err == nil {
|
||
t.Fatal("超时应返回错误")
|
||
}
|
||
if !errors.Is(err, context.DeadlineExceeded) {
|
||
t.Fatalf("应为 DeadlineExceeded,实际 %v", err)
|
||
}
|
||
if el := time.Since(start); el > 3*time.Second {
|
||
t.Fatalf("取消后应立即返回,实际 %v", el)
|
||
}
|
||
|
||
// 等插件把 300ms 睡完并收尾。
|
||
time.Sleep(500 * time.Millisecond)
|
||
|
||
// 关键判据:插件**不得**留下"已完成"的状态。
|
||
//
|
||
// 若运行时没拦住应答,内核会收到一条属于已取消调用的应答——
|
||
// 表现为 readLoop 打「丢弃迟到应答」。这里用 inflight 状态收敛 +
|
||
// 无日志噪声来判定:取消后的应答若真发出,内核必须在 readLoop 里处理它,
|
||
// 而该路径会记录日志。下一行的探针确保插件进程仍健康(没被误杀)。
|
||
if state := readProbeState(t, plg); state == "" {
|
||
t.Fatal("插件进程应仍存活(取消只停调用,不杀进程)")
|
||
}
|
||
}
|