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>
This commit is contained in:
JianFeeeee
2026-10-25 10:06:48 +08:00
parent 90d1289b2d
commit f5a1366770
17 changed files with 1475 additions and 51 deletions

View File

@ -72,6 +72,27 @@ const (
MethodPluginStop = "plugin.stop"
// MethodToolInvoke 执行插件工具。
MethodToolInvoke = "tool.invoke"
// MethodCallCancel 通知插件**放弃**某个仍在执行的调用。
//
// ## 为什么取消必须跨进程
//
// 内核超时到点后自己不再等(toolcall.go 用 select + time.After),但
// **插件侧的 handler 还在跑**:它不知道没人要这个结果了,会继续占着
// 连接/子进程/文件句柄直到自己跑完。旧实现里内核只能把该请求从 pending
// 表里删掉——结果被丢弃,成本照付。
//
// ## 为什么不能让插件作者改签名
//
// 一个自然的想法是给 ToolHandler 加 context.Context,但那要求**所有**
// 插件跟着改(仓内 100 处 + 全部存量插件),而“让内核能停下一个不想停的
// 调用”本来就是**内核的**责任,不该把负担转给 100 个作者。
//
// 所以取消走这里:内核发一条**无 id 的通知帧**(不等于任何请求),
// 插件运行时按 CallID 找到正在跑的 handler,把它的 ctx cancel 掉。
// handler 签名不变 —— 它在下一次阻塞调用(HTTP/子进程/DB)返回 context.Canceled 时自然退出。
//
// 参数:CallID 是**被取消 đó 请求的 RPC id**(即 tool.invoke 帧的 id)。
MethodCallCancel = "call.cancel"
// MethodCleanerInvoke 在插件进程内执行工具或通道声明的 Cleaner。
// Cleaner 是函数,不能随注册请求 JSON 序列化;内核保留 RPC 回调闭包,
// 需要参与向量化/蒸馏时把原文送回插件执行真正的 Cleaner。
@ -296,6 +317,18 @@ type ToolInvokeResult struct {
Result interface{} `json:"result,omitempty"` // 仅直连 RPC 调用方使用
}
// CallCancelParams 是 call.cancel 通知的参数。
//
// CallID 是**要取消的那个请求的 RPC id**(tool.invoke / cleaner.invoke 帧的 id)。
// 插件运行时会按它找到仍在执行的 handler 并 cancel 其 ctx。
//
// 注意这是**通知**(ID=0),插件不应回响应:取消路径绝不能变成新的同步点,
// 否则内核超时时反而被“等插件确认取消”再拖一轮。
type CallCancelParams struct {
CallID uint64 `json:"call_id"`
Reason string `json:"reason,omitempty"`
}
// CleanerInvokeParams / CleanerInvokeResult:跨进程计算层清洗。
// Scope 取 tool / input / output,Name 是工具名或通道名。
//
@ -362,6 +395,20 @@ type OutputInvokeResult struct {
// 改成实际 payload 长度再随业务 RPC 回传。
type ArenaAllocParams struct {
Size uint32 `json:"size"`
// CallID 是**本块归属于哪次调用**(tool.invoke / cleaner.invoke 的 RPC id)。
//
// ## 为什么必须带它(2026-10-25)
//
// 扩容块是插件为“把大结果回给内核”而申请的。但调用可能已被内核取消:
// 插件静默退出、内核从未看到那个 ResultRef,于是这块**永远不会被归还**——
// 内核手上没有任何句柄能指向它。这就是“取消了却瞒下了内存”的真实泄漏形态。
//
// 带上 CallID 后,内核就能在“取消超时”时把该调用名下的块全部强制回收,
// 而不需要信任对端会归还。
//
// 0 表示未归属(旧模板/直连调用)——此时退回旧行为:只能等插件自己归还。
CallID uint64 `json:"call_id,omitempty"`
}
type ArenaAllocResult struct {