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

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"log"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
@ -32,6 +33,10 @@ type coreHandler struct {
// 并在插件退出时 ReclaimOwner 回收残留槽。
owner uint32
// process 是本插件的子进程句柄,用于按 CallID 找到对应的在途调用
// (插件申请扩容块时要把块登记到那次调用名下。
process *Process
// locks 是 host.locks 的引用,供 stage.lock/unlock 路由。
locks *lockRegistry
@ -410,8 +415,13 @@ func (h *coreHandler) invokeCleanerText(scope, name, text string) (string, error
if res.TextRef.IsZero() {
return "", fmt.Errorf("%s %s Cleaner 未返回结果引用", scope, name)
}
// ★ owner 必须是插件自己(h.owner),理由同 plugin.freeExpand。
if res.TextRef.Flags&sharedRefFlagExpand != 0 {
defer func() { _ = arena.Free(OwnerHost, res.TextRef) }()
defer func() {
if err := arena.Free(h.owner, res.TextRef); err != nil {
log.Printf("[proc] %s %s Cleaner 扩容块归还失败(arena 将缓慢泄漏): %v", scope, name, err)
}
}()
}
data, err := arena.Read(res.TextRef, gen)
if err != nil {
@ -428,8 +438,23 @@ func (h *coreHandler) invokeCleanerText(scope, name, text string) (string, error
// arenaAlloc 给本插件分配一块共享内存。
//
// 用途:结果超出内核标定帧的预算时,插件据此申请扩容块。
func (h *coreHandler) arenaAlloc(size uint32) (SharedRef, error) {
return h.host.Arena().Alloc(h.owner, int(size), h.host.Generation())
//
// callID 非 0 时把该块**登记到这次调用名下**,供取消超时时强制回收:
// 插件被取消后可能静默退出(正确行为),内核就再也看不到那个 ResultRef,
// 块会永久泄漏。有了归属,内核能在"取消也超时"时自己把账清了。
func (h *coreHandler) arenaAlloc(size uint32, callID uint64) (SharedRef, error) {
ref, err := h.host.Arena().Alloc(h.owner, int(size), h.host.Generation())
if err != nil {
return SharedRef{}, err
}
if callID != 0 {
// 登记到**那次调用**名下(不是另立一张表):取消超时时由那次调用
// 的收尾逻辑强制回收,见 Process.abandon / call.takeBlocks。
if c := h.process.CallByID(callID); c != nil {
c.trackBlock(ref)
}
}
return ref, nil
}
// arenaFree 归还本插件申请的块。