Files
HomeAgent/internal/plugin/proc/plugin.go
JianFeeeee b096e8ba2c refactor(shm): 变长块分配器 + 工具调用 funccall 调用帧(§13.2/§13.3)
按架构约束推进两步:

1) 分配器收回内核后,定长槽失去了唯一存在理由
   (定长槽只是为了绕开"跨进程无法安全变长分配"),
   于是 arena 换成**变长块分配器**:first-fit + 邻块合并,
   块头 16B(size/state/owner/prevSize)。内核可以按需标定每块大小,
   payload 不再受固定槽容量限制(旧上限 16KB)。

   - Alloc/Put/Read/Free/ReclaimOwner 全在内核进程内,一把 sync.Mutex
   - Free 走块链校验 offset 是已分配块的数据起点 + owner 匹配,
     伪造引用不能改动分配器状态
   - Read 允许块内偏移(调用帧的结果区就在帧块中间),但不许跨块边界
   - arena 4MB,底层 memfd 惰性分配:未触碰的页不占物理内存

2) 工具调用 payload **始终**走共享内存,取消按大小切内联的分支。
   按 funccall 模型,内核(caller)标定调用帧交给插件(callee):

       [0, ArgsLen)              参数 JSON
       [ArgsLen, Frame.Length)   结果区(内核预留的预算)

   结果超出预算时插件才 `arena.alloc` 扩容块,引用上打
   sharedRefFlagExpand 让内核单独归还。Cleaner 复用同一帧模型。
   ToolInvokeParams.Args / ToolInvokeResult.Result 仅剩给直连 RPC 的
   测试(process/bench 不建 Host);生产路径永远走帧。

测试:
- TestArena_*(分配/归属/回收/并发唯一/耗尽/超限/伪造 offset/
  相邻合并/对齐/布局校验)
- TestPlugin_ToolInvokeArgsResultViaArena:大**小** payload 都经帧往返,
  结果一致且 arena 归零("小 payload 同样走调用帧"是本轮行为变更)
- TestPlugin_ArenaAllocFreeAcrossProcess、TestE2E_* 保持通过
- 顺手修 process_test.go 一处预存 doc-comment 分段
2026-09-10 18:54:36 +08:00

369 lines
12 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 (
"context"
"encoding/json"
"fmt"
"log"
"sync"
"sync/atomic"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// Plugin 是 registry 可加载的子进程插件,与内置插件同构的启停接口。
//
// 生命周期:
//
// New() 创建(尚未 spawn
// Start(core) spawn 子进程 → 握手(传共享段 fd→ plugin.init → plugin.start
// plugin.start 期间插件反向注册工具/阶段/通道)
// Stop() plugin.stop → 宽限期 → 必要时 Kill
// Close() 强制结束registry 卸载/重载路径)
//
// **共享段不属于 Plugin**:它属于 Host被全部子进程插件共享。
// 若每插件一段,「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下会退化成
// 副本模型lost update 原样复现§8.4)。
type Plugin struct {
name string
bin string
dir string
config map[string]interface{}
host *Host
proc *Process
handler *coreHandler
// env 追加到子进程环境变量(测试用;生产由 registry 按需设置)。
env []string
// onCrash 由 registry 注入,把进程退出喂给 plugin_health.recordCrash§2.3)。
onCrash func(name string, err error)
// caps 是 manifest 声明的能力集§3.8 权限梯度)。
caps *capabilitySet
// ownerID 是本插件在共享槽池里的身份(由 Host 分配)。
// 内核用它校验 arena.free 的归属,并在插件退出时回收残留槽。
ownerID uint32
stopOnce sync.Once
// stopping 标记「本次退出是内核主动发起的」,用于压掉 onCrash。
//
// 必要性Stop() 宽限期超时与 Close() 都走 Process.Kill()
// 而 Kill 产生的 `signal: killed` 是非 nil 的 waitErr——若不区分
// 重载/禁用/卸载这些**内核自己发起**的停止会被 handleExit 当成崩溃上报,
// 触发一轮多余的自动重启(重载路径下等于把刚装好的插件又推倒一次)。
stopping atomic.Bool
}
// New 创建子进程插件(不启动进程)。
//
// host 必须是全部子进程插件共用的实例(由 registry 创建一次)。
// New 创建子进程插件(不启动进程)。
//
// host 必须是全部子进程插件共用的实例(由 registry 创建一次)。
// capabilities 来自 manifest 的 capabilities 字段;为空时不限制(存量插件向后兼容)。
func New(name, bin, dir string, config map[string]interface{}, host *Host, onCrash func(string, error), capabilities ...string) *Plugin {
return &Plugin{
name: name,
bin: bin,
dir: dir,
config: config,
host: host,
onCrash: onCrash,
caps: newCapabilitySet(capabilities),
}
}
// Name 实现 sdk.Plugin。
func (p *Plugin) Name() string { return p.name }
// PID 返回子进程号;未启动或已退出返回 0。
// 供 pluginmgr 呈现「插件实际在跑哪个进程」。
func (p *Plugin) PID() int {
if p.proc == nil {
return 0
}
if !p.Alive() {
return 0
}
return p.proc.PID()
}
// Alive 报告子进程是否仍存活。
func (p *Plugin) Alive() bool {
if p.proc == nil {
return false
}
select {
case <-p.proc.Exited():
return false
default:
return true
}
}
// Start 启动子进程并完成注册。
//
// core 是内核为该插件构建的能力面internal/sdk.PluginSDK 天然满足 CoreSDK
func (p *Plugin) Start(core CoreSDK) error {
if p.host == nil {
return fmt.Errorf("proc: %s 缺少共享段 Host", p.name)
}
// 领一个槽池身份;插件退出时用它回收残留槽。
p.ownerID = p.host.NextOwnerID()
p.handler = &coreHandler{
sdk: core,
name: p.name,
host: p.host,
owner: p.ownerID,
locks: p.host.locks,
evtRing: p.host.evtSubscriber,
caps: p.caps,
}
// 反向调用闭包:注册回调时捕获,运行期经 RPC 打到插件进程。
p.handler.invokeTool = p.invokeTool
p.handler.invokeCleaner = p.invokeCleaner
p.handler.invokeStageFn = p.invokeStage
p.handler.invokeOutput = p.invokeOutput
proc, err := Spawn(p.name, p.bin, Options{
Dir: p.dir,
// 共享段的传递机制按平台不同shmpass_*.go
// Unix 经 ExtraFiles 传继承 fd 3=StageContext, 4=事件环, 5=通知);
// Windows 无 fd 继承语义,改用命名内核对象,名字经环境变量传入。
Env: append(p.env, p.host.procEnvForShm()...),
ExtraFiles: p.host.procExtraFilesForShm(),
ShmSize: p.host.shmSize,
EvtRingSize: evtTotalSize,
Handler: p.handler.Handle,
OnExit: p.handleExit,
Supervisor: p.host.Supervisor(),
})
if err != nil {
return err
}
p.proc = proc
// plugin.init构造插件实例
if _, err := proc.Call(MethodPluginInit, PluginInitParams{
Name: p.name,
Config: p.config,
}); err != nil {
proc.Kill()
return fmt.Errorf("proc: %s plugin.init 失败: %w", p.name, err)
}
// plugin.start插件在此期间反向注册工具/阶段/通道
if _, err := proc.Call(MethodPluginStart, nil); err != nil {
proc.Kill()
return fmt.Errorf("proc: %s plugin.start 失败: %w", p.name, err)
}
return nil
}
// Stop 优雅停止(实现 sdk.Plugin
func (p *Plugin) Stop() error {
p.stopping.Store(true)
var err error
p.stopOnce.Do(func() {
if p.proc != nil {
err = p.proc.Stop()
}
})
return err
}
// Close 强制结束子进程。
//
// **这里是真 kill + wait**——对比 cabi 路径的 Close 只做 dlclose
// 而 dlclose 对 Go c-shared 是 no-op§1.1,热重载静默失效的根因)。
func (p *Plugin) Close() error {
p.stopping.Store(true)
var err error
p.stopOnce.Do(func() {
if p.proc != nil {
err = p.proc.Kill()
}
})
return err
}
// handleExit 在子进程退出时把信号喂给 plugin_health§2.3 逻辑复用),
// 并释放该插件可能持有的 stage 锁。
//
// 后者是"锁仲裁回内核"的自愈价值:持锁者死亡不会导致全局死锁,
// 无需 robust pthread_mutex实验 9
func (p *Plugin) handleExit(name string, err error) {
if p.host != nil {
if p.host.ForceReleaseLock(name) {
log.Printf("[proc] %s 退出,内核已释放其持有的 stage 锁", name)
}
// 回收该插件未归还的共享槽:崩溃的插件无法自己归还,
// 不回收会让槽池慢慢耗尽,最终所有共享内存调用退化成内联 RPC。
if n := p.host.ReclaimOwner(p.ownerID); n > 0 {
log.Printf("[proc] %s 退出,回收 %d 个残留共享槽", name, n)
}
}
// 内核主动停止Stop/Close含宽限期超时后的 Kill不算崩溃
// 否则重载/禁用/卸载都会误触发自动重启。
if p.stopping.Load() {
return
}
if err != nil && p.onCrash != nil {
p.onCrash(name, err)
}
}
// ---- 内核 → 插件的反向调用 ----
// invokeTool 在插件进程内执行工具。
//
// 按 **funccall 模型**:内核是 caller为每次调用**标定一块内存帧**
// (参数段 + 结果预算段交给插件callee。参数永远写在帧里不再有
// “小 payload 走内联”的按大小分支。
//
// 结果超出预算时插件才向内核申请扩容块,并在引用上打
// sharedRefFlagExpand内核据此单独归还。
//
// 控制面仍是 RPC请求 ID 关联、ctx 取消、崩溃唤醒都由 Process 承载)。
func (p *Plugin) invokeTool(name string, args map[string]interface{}) (interface{}, error) {
if p.proc == nil {
return nil, ErrProcessExited
}
arena := p.host.Arena()
gen := p.host.Generation()
argJSON, err := json.Marshal(args)
if err != nil {
return nil, fmt.Errorf("proc: %s 工具 %s 参数序列化失败: %w", p.name, name, err)
}
// 内核标定调用帧:参数段 + 结果预算段。
frame, err := arena.Alloc(OwnerHost, len(argJSON)+toolResultBudget, gen)
if err != nil {
return nil, fmt.Errorf("proc: %s 工具 %s 分配调用帧失败: %w", p.name, name, err)
}
defer func() { _ = arena.Free(OwnerHost, frame) }()
area, err := arena.Read(frame, gen)
if err != nil {
return nil, fmt.Errorf("proc: %s 工具 %s 读取调用帧失败: %w", p.name, name, err)
}
copy(area[:len(argJSON)], argJSON)
raw, err := p.proc.Call(MethodToolInvoke, ToolInvokeParams{
Name: name,
Frame: frame,
ArgsLen: uint32(len(argJSON)),
})
if err != nil {
return nil, err
}
var res ToolInvokeResult
if err := json.Unmarshal(raw, &res); err != nil {
return nil, fmt.Errorf("proc: %s 工具 %s 应答解析失败: %w", p.name, name, err)
}
if res.ResultRef.IsZero() {
return nil, fmt.Errorf("proc: %s 工具 %s 未返回结果引用", p.name, name)
}
// 插件申请了扩容块:内核负责归还。
if res.ResultRef.Flags&sharedRefFlagExpand != 0 {
defer func() { _ = arena.Free(OwnerHost, res.ResultRef) }()
}
data, err := arena.Read(res.ResultRef, gen)
if err != nil {
return nil, fmt.Errorf("proc: %s 工具 %s 读取共享结果失败: %w", p.name, name, err)
}
var out interface{}
if err := json.Unmarshal(data, &out); err != nil {
return nil, fmt.Errorf("proc: %s 工具 %s 解析共享结果失败: %w", p.name, name, err)
}
return out, nil
}
// invokeCleaner 在插件进程内执行工具或通道注册时提供的 Cleaner 函数。
//
// 数据面参数(输入槽 / 预分配响应槽)由内核在 params 里给出,
// 插件只负责读输入、写结果,不做任何分配。
func (p *Plugin) invokeCleaner(params CleanerInvokeParams) (CleanerInvokeResult, error) {
if p.proc == nil {
return CleanerInvokeResult{}, ErrProcessExited
}
raw, err := p.proc.Call(MethodCleanerInvoke, params)
if err != nil {
return CleanerInvokeResult{}, err
}
var res CleanerInvokeResult
if err := json.Unmarshal(raw, &res); err != nil {
return CleanerInvokeResult{}, fmt.Errorf("proc: %s %s Cleaner %s 应答解析失败: %w", p.name, params.Scope, params.Name, err)
}
return res, nil
}
func (p *Plugin) invokeStage(ctx context.Context, stage string, seq uint64) error {
if p.proc == nil {
return ErrProcessExited
}
raw, err := p.proc.CallContext(ctx, MethodStageInvoke, StageInvokeParams{
Stage: stage,
Seq: seq,
})
if err != nil {
return err
}
var res StageInvokeResult
if len(raw) > 0 {
if err := json.Unmarshal(raw, &res); err != nil {
return fmt.Errorf("proc: %s stage %s 应答解析失败: %w", p.name, stage, err)
}
}
if res.DirtyFields > 0 {
log.Printf("[proc] %s stage %s 改写了 %d 个字段", p.name, stage, res.DirtyFields)
}
return nil
}
// invokeOutput 经插件输出通道发送,**同步等待真实结果**。
//
// 这是 §9.4 的根治C ABI 下 cgo 不可嵌套,只能异步 fire-and-forget
// 导致 output_send 永远返回 {status:queued} + err=nil模型永远以为发送成功
// (现网 7 天内 2 次消息实际发不出)。进程模型下 RPC 天然可等应答。
func (p *Plugin) invokeOutput(channel string, args map[string]interface{}) (interface{}, error) {
if p.proc == nil {
return nil, ErrProcessExited
}
raw, err := p.proc.Call(MethodOutputInvoke, OutputInvokeParams{
Channel: channel,
Args: args,
})
if err != nil {
return nil, err // 真实失败上报,模型可感知并重试
}
if len(raw) == 0 {
return map[string]interface{}{"status": "sent"}, nil
}
var res map[string]interface{}
if err := json.Unmarshal(raw, &res); err != nil {
return map[string]interface{}{"status": "sent"}, nil
}
if _, ok := res["status"]; !ok {
res["status"] = "sent"
}
return res, nil
}
// 编译期确认 Plugin 具备 registry 需要的启停形状。
var _ interface {
Name() string
Stop() error
Close() error
} = (*Plugin)(nil)
// 引用一下公开 SDK确保本文件的类型假设与它同版本。
var _ = pubsdk.StageScopeGlobal