feat(proc): 子进程通道 —— RPC 协议 + 进程管理(Part 2 核心)

协议面(protocol.go,§3.2 method id 平移为 method 名):
- NDJSON 帧,双向复用同一对 stdio;ID>0 需应答,ID==0 为通知(post-and-forget)
- 51 个 C ABI method id 全部平移为可读 method 名并标注原编号对照
  编号本身扔掉——加能力不用改两边常量表,不再有 47 夹在 7 和 8 之间的痕迹
- case 25(CORE_FREE_STRING) 无对应 method:进程模型下各自 GC,概念消失
- case 23/24(事件订阅) 与 io.setToolBlocks 今日均为空实现「给不了」,
  子进程下首次真正可给(§3.8 能力对齐)
- 新增 stage.lock/stage.unlock(C ABI 下不存在跨进程锁概念)
- StageInvokeParams 不含 StageContext 数据本身——数据在共享段,只带 stage 名 + seq

进程面(process.go,§2.3 保留现有生命周期机制):
- Spawn: 启动 + 握手(协议版本不匹配显式拒绝,不半兼容运行)
- readLoop: NDJSON 分派应答/插件反向请求,1MB 单帧上限(大 payload 走 arena)
- CallContext: ctx 取消时立即返回**且清理 pending 条目**
  对比 cgo:超时只让调用方返回,goroutine 永久卡在 C 调用里(现网泄漏 26 次)
- Notify: ID=0 不占 pending 表,满足约束 B(流式逐 token 发布不得等待消费者)
- markExited: EOF/退出 → 唤醒全部在途调用 → onExit 回调
  这是「把 panic 捕获换成进程退出检测」的落点,plugin_health 逻辑完全复用
- Stop: plugin.stop → 宽限期 → 超时 Kill;Kill 后 OS 回收全部资源,零泄漏
- serveRequest 带 panic 隔离:内核 handler panic 不带崩 readLoop

验证(10 项,真实子进程而非 mock,含 -race):
- 握手/工具调用/错误上报(插件失败调用方收到 error,非假成功)
- 插件反向调用内核(tool.register + settings.get 双向往返)
- **崩溃隔离**:插件 panic → 子进程 exit 2,内核存活、收到 onExit、在途调用不挂死
- 优雅停止 / **Kill 卡死插件**(ctx 超时返回 + pending 清零 + 资源回收)
- 通知不等应答(100 条 < 1s)/ 50 并发调用应答不串 / 协议版本不匹配拒绝

接口冻结: git diff third_party/homeagent-sdk/sdk/ 为空
This commit is contained in:
dev
2026-09-02 10:59:59 +08:00
parent bfa95ba320
commit d62430a71b
8 changed files with 1406 additions and 0 deletions

View File

@ -0,0 +1,499 @@
package proc
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"sync"
"sync/atomic"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
)
// Process 管理一个外部插件子进程spawn / 双向 JSON-RPC / 优雅停止 / 崩溃检测。
//
// 设计依据docs/zh/架构迁移评估.md §4.1 阶段 2、§2.3(保留现有生命周期机制)
//
// 与 C ABI 路径的关键差异:
// - **崩溃隔离**:插件 panic 只让子进程退出homed 存活(今日 panic 跨 C 栈可带崩内核)
// - **真正的取消**Kill() 后 OS 回收全部资源,零泄漏
// (今日 cgo 调用不可抢占,超时后 OS 线程永久占用,现网已泄漏 26 次§9.3
// - **可同步等真实结果**RPC 天然可等应答
// (今日 cgo 不可嵌套output_send 只能异步、永远假成功§9.4
type Process struct {
name string
bin string
dir string
cmd *exec.Cmd
stdin *bufio.Writer
stdout io.ReadCloser
// writeMu 串行化 stdin 写入NDJSON 帧不能交错,否则对端解析错乱。
writeMu sync.Mutex
// pending 表:请求 ID → 应答通道。
mu sync.Mutex
nextID uint64
pending map[uint64]chan *Response
closed bool
// handler 处理插件反向发起的调用51 个 core.* method
handler RequestHandler
// exited 在 readLoop 检测到 EOF/进程退出后关闭,用于唤醒所有等待者。
exited chan struct{}
exitOnce sync.Once
exitErr atomic.Pointer[error]
readerWG sync.WaitGroup
readyOnce sync.Once
ready chan struct{}
// onExit 在进程退出时回调(内核用它喂 plugin_health.recordCrash
// 以及 ForceRelease 释放该插件持有的 stage 锁)。
onExit func(name string, err error)
// shmSize 是握手时告知插件的共享段大小0 表示本插件不用共享段)。
shmSize int
}
// RequestHandler 处理插件 → 内核的调用。
// 返回值会被序列化为 Response.Result返回 error 则序列化为 Response.Error。
type RequestHandler func(method string, params json.RawMessage) (interface{}, error)
// Options 是 Spawn 的可选配置。
type Options struct {
// Dir 是子进程工作目录(通常为插件目录)。
Dir string
// Env 追加到子进程环境变量。
Env []string
// ExtraFiles 传给子进程的额外文件描述符fd 3 起)。
// 共享内存段的 memfd 经此传递——子进程 mmap fd 3 即挂载同一段。
ExtraFiles []*os.File
// ShmSize 是共享段大小,握手时告知插件(与 ExtraFiles[0] 的 memfd 对应)。
ShmSize int
// Handler 处理插件反向调用。
Handler RequestHandler
// OnExit 进程退出回调。
OnExit func(name string, err error)
// HandshakeTimeout 建链超时,默认 10s。
HandshakeTimeout time.Duration
}
// 默认超时。
const (
defaultHandshakeTimeout = 10 * time.Second
// stopGracePeriod 是发出 plugin.stop 后等待进程自行退出的时间。
// 超时则 Kill——**这是"真正的取消"**,对比 cgo 路径超时后线程永久泄漏。
stopGracePeriod = 5 * time.Second
)
// ErrProcessExited 表示子进程已退出,调用无法完成。
var ErrProcessExited = errors.New("proc: 插件进程已退出")
// Spawn 启动插件子进程并完成握手。
func Spawn(name, bin string, opts Options) (*Process, error) {
if opts.Handler == nil {
return nil, fmt.Errorf("proc: %s 缺少 RequestHandler插件无法回调内核", name)
}
timeout := opts.HandshakeTimeout
if timeout <= 0 {
timeout = defaultHandshakeTimeout
}
cmd := exec.Command(bin)
cmd.Dir = opts.Dir
// stderr 直通内核日志:插件的 panic 栈、log 输出可直接看到。
cmd.Stderr = os.Stderr
if len(opts.Env) > 0 {
cmd.Env = append(os.Environ(), opts.Env...)
}
cmd.ExtraFiles = opts.ExtraFiles
stdinPipe, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("proc: %s stdin 管道: %w", name, err)
}
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("proc: %s stdout 管道: %w", name, err)
}
p := &Process{
name: name,
bin: bin,
dir: opts.Dir,
cmd: cmd,
stdin: bufio.NewWriter(stdinPipe),
stdout: stdoutPipe,
pending: make(map[uint64]chan *Response),
handler: opts.Handler,
exited: make(chan struct{}),
ready: make(chan struct{}),
onExit: opts.OnExit,
shmSize: opts.ShmSize,
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("proc: 启动 %s (%s): %w", name, bin, err)
}
p.readerWG.Add(1)
go p.readLoop()
// 等 readLoop 就绪后再握手,避免应答早于 reader 启动而丢失。
<-p.ready
if err := p.handshake(timeout); err != nil {
p.Kill()
return nil, err
}
return p, nil
}
// Name 返回插件名。
func (p *Process) Name() string { return p.name }
// PID 返回子进程 PID用于诊断/日志)。
func (p *Process) PID() int {
if p.cmd == nil || p.cmd.Process == nil {
return 0
}
return p.cmd.Process.Pid
}
// Exited 返回一个在进程退出时关闭的通道。
func (p *Process) Exited() <-chan struct{} { return p.exited }
// ExitError 返回进程退出原因(正常退出为 nil
func (p *Process) ExitError() error {
if e := p.exitErr.Load(); e != nil {
return *e
}
return nil
}
func (p *Process) handshake(timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
raw, err := p.CallContext(ctx, MethodHandshake, HandshakeParams{
Protocol: ProtocolVersion,
CoreVersion: meta.Version,
PluginName: p.name,
ShmVersion: shmVersion,
ShmSize: p.shmSize,
})
if err != nil {
return fmt.Errorf("proc: %s 握手失败: %w", p.name, err)
}
var res HandshakeResult
if err := json.Unmarshal(raw, &res); err != nil {
return fmt.Errorf("proc: %s 握手应答解析失败: %w", p.name, err)
}
if res.Protocol != ProtocolVersion {
return fmt.Errorf("proc: %s 协议版本不匹配(插件 %d内核 %d——请用配套 plugindev 重编",
p.name, res.Protocol, ProtocolVersion)
}
log.Printf("[proc] %s 已建链pid=%d protocol=%d sdk=%s",
p.name, p.PID(), res.Protocol, res.SDKVersion)
return nil
}
// readLoop 读取子进程 stdout 的 NDJSON 帧,分派为「应答」或「插件发起的请求」。
//
// 参考 clawhubadapter/sidecarProcess 的成熟做法:大 buffer 防长行截断、
// pending 表定位应答、退出时唤醒全部等待者。
func (p *Process) readLoop() {
defer p.readerWG.Done()
scanner := bufio.NewScanner(bufio.NewReader(p.stdout))
// 单帧上限 1MB控制面帧本应很小工具结果中位 93B
// 超大 payload 应走共享内存 arena 而非 RPC 帧。
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
p.readyOnce.Do(func() { close(p.ready) })
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
// 帧可能是 Response有 id 无 method或 Request有 method
var probe struct {
ID uint64 `json:"id"`
Method string `json:"method"`
}
if err := json.Unmarshal(line, &probe); err != nil {
log.Printf("[proc] %s 收到非法 JSON 帧(%d 字节): %v", p.name, len(line), err)
continue
}
if probe.Method != "" {
// 插件发起的调用:拷贝一份再交给 goroutinescanner 会复用底层数组)
buf := make([]byte, len(line))
copy(buf, line)
go p.serveRequest(buf)
continue
}
var resp Response
if err := json.Unmarshal(line, &resp); err != nil {
log.Printf("[proc] %s 应答解析失败: %v", p.name, err)
continue
}
p.mu.Lock()
ch, ok := p.pending[resp.ID]
delete(p.pending, resp.ID)
p.mu.Unlock()
if !ok {
log.Printf("[proc] %s 收到未知 id=%d 的应答(可能已超时)", p.name, resp.ID)
continue
}
ch <- &resp
}
if err := scanner.Err(); err != nil {
log.Printf("[proc] %s 读取 stdout 出错: %v", p.name, err)
}
// stdout 关闭EOF意味着进程结束——2.5ms 内即可感知(实验 6
p.markExited()
}
// markExited 回收进程、唤醒所有等待者、触发 onExit 回调。
//
// 这是「把 panic 捕获换成进程退出检测」的落点§2.3
// plugin_health 的 recordCrash / 冷却 / 自愈 / pendingReloads 全部逻辑复用,
// 只是信号源从 recover() 变成进程退出。
func (p *Process) markExited() {
p.exitOnce.Do(func() {
waitErr := p.cmd.Wait()
if waitErr != nil {
e := fmt.Errorf("插件进程 %s 异常退出: %w", p.name, waitErr)
p.exitErr.Store(&e)
log.Printf("[proc] %s 退出: %v", p.name, waitErr)
} else {
log.Printf("[proc] %s 正常退出", p.name)
}
p.mu.Lock()
p.closed = true
waiters := make([]chan *Response, 0, len(p.pending))
for id, ch := range p.pending {
waiters = append(waiters, ch)
delete(p.pending, id)
}
p.mu.Unlock()
// 唤醒所有在途调用,避免调用方挂死到自己的超时
for _, ch := range waiters {
ch <- &Response{Error: ErrProcessExited.Error()}
}
close(p.exited)
if p.onExit != nil {
p.onExit(p.name, p.ExitError())
}
})
}
// serveRequest 处理插件反向发起的调用。
func (p *Process) serveRequest(line []byte) {
var req Request
if err := json.Unmarshal(line, &req); err != nil {
log.Printf("[proc] %s 请求解析失败: %v", p.name, err)
return
}
// panic 隔离:插件的回调参数可能触发内核 handler 的 panic
// 不能让它带崩整个 readLoop更不能带崩 homed
var (
result interface{}
err error
)
func() {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("内核 handler 处理 %s 时 panic: %v", req.Method, r)
log.Printf("[proc] %s: %v", p.name, err)
}
}()
result, err = p.handler(req.Method, req.Params)
}()
// ID==0 是通知不回应答§2.4 约束 Bpost-and-forget
if req.ID == 0 {
if err != nil {
log.Printf("[proc] %s 通知 %s 处理失败: %v", p.name, req.Method, err)
}
return
}
resp := Response{ID: req.ID}
if err != nil {
resp.Error = err.Error()
} else if result != nil {
if b, mErr := json.Marshal(result); mErr == nil {
resp.Result = b
} else {
resp.Error = fmt.Sprintf("结果序列化失败: %v", mErr)
}
}
if wErr := p.writeFrame(&resp); wErr != nil {
log.Printf("[proc] %s 回写应答失败: %v", p.name, wErr)
}
}
// writeFrame 序列化并写入一帧串行化NDJSON 不能交错)。
func (p *Process) writeFrame(v interface{}) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
p.writeMu.Lock()
defer p.writeMu.Unlock()
if _, err := p.stdin.Write(b); err != nil {
return err
}
if err := p.stdin.WriteByte('\n'); err != nil {
return err
}
return p.stdin.Flush()
}
// Call 发起 RPC 并等待应答(无超时上限,由调用方 context 控制)。
func (p *Process) Call(method string, params interface{}) (json.RawMessage, error) {
return p.CallContext(context.Background(), method, params)
}
// CallContext 发起 RPC 并等待应答,受 ctx 取消/超时控制。
//
// **ctx 取消时调用方立即返回,且 pending 条目被清理**——
// 对比 cgo 路径超时只让调用方返回goroutine 仍永久卡在 C 调用里§9.3)。
// 这里子进程若真卡住,上层可 Kill()OS 回收全部资源。
func (p *Process) CallContext(ctx context.Context, method string, params interface{}) (json.RawMessage, error) {
var raw json.RawMessage
if params != nil {
b, err := json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("proc: %s 序列化 %s 参数: %w", p.name, method, err)
}
raw = b
}
ch := make(chan *Response, 1)
p.mu.Lock()
if p.closed {
p.mu.Unlock()
return nil, fmt.Errorf("proc: %s 调用 %s: %w", p.name, method, ErrProcessExited)
}
p.nextID++
id := p.nextID
p.pending[id] = ch
p.mu.Unlock()
if err := p.writeFrame(&Request{ID: id, Method: method, Params: raw}); err != nil {
p.mu.Lock()
delete(p.pending, id)
p.mu.Unlock()
return nil, fmt.Errorf("proc: %s 发送 %s: %w", p.name, method, err)
}
select {
case resp := <-ch:
if resp.Error != "" {
return nil, fmt.Errorf("proc: %s.%s: %s", p.name, method, resp.Error)
}
return resp.Result, nil
case <-ctx.Done():
p.mu.Lock()
delete(p.pending, id)
p.mu.Unlock()
return nil, fmt.Errorf("proc: %s 调用 %s: %w", p.name, method, ctx.Err())
case <-p.exited:
return nil, fmt.Errorf("proc: %s 调用 %s: %w", p.name, method, ErrProcessExited)
}
}
// Notify 发送不需要应答的通知ID=0fire-and-forget
//
// 用于事件投递等路径:内核发通知**绝不等待消费者**§2.4 约束 B——
// 流式输出逐 token 发布,任何等待都会造成卡顿)。
func (p *Process) Notify(method string, params interface{}) error {
var raw json.RawMessage
if params != nil {
b, err := json.Marshal(params)
if err != nil {
return err
}
raw = b
}
p.mu.Lock()
closed := p.closed
p.mu.Unlock()
if closed {
return ErrProcessExited
}
return p.writeFrame(&Request{Method: method, Params: raw})
}
// Stop 优雅停止:发 plugin.stop → 等宽限期 → 超时则 Kill。
//
// 插件侧收到 plugin.stop 后应先跑 RunStopHandlers 再 Stop()
// 与 C ABI 路径的停止链路语义一致§2.3 已验证被正确调用)。
func (p *Process) Stop() error {
select {
case <-p.exited:
return nil // 已经退出
default:
}
ctx, cancel := context.WithTimeout(context.Background(), stopGracePeriod)
defer cancel()
if _, err := p.CallContext(ctx, MethodPluginStop, nil); err != nil {
// 停止调用失败不影响后续 Kill——插件可能已经崩了
if !errors.Is(err, ErrProcessExited) {
log.Printf("[proc] %s plugin.stop 失败(将强制结束): %v", p.name, err)
}
}
select {
case <-p.exited:
return nil
case <-time.After(stopGracePeriod):
log.Printf("[proc] %s 宽限期内未退出,强制结束", p.name)
return p.Kill()
}
}
// Kill 强制结束子进程并回收资源。
//
// **这是 C ABI 路径拿不到的能力**cgo 调用不可被 Go runtime 抢占或取消,
// 超时后该 OS 线程永久占用(实验 14 实测 20 次调用线性泄漏 +18 线程)。
// 子进程模型下 Kill 后 OS 回收全部资源,零泄漏。
func (p *Process) Kill() error {
if p.cmd == nil || p.cmd.Process == nil {
return nil
}
err := p.cmd.Process.Kill()
// 等 readLoop 观察到 EOF 并完成 Wait/清理
select {
case <-p.exited:
case <-time.After(2 * time.Second):
p.markExited() // 兜底:极端情况下强制走清理
}
p.readerWG.Wait()
if err != nil && !errors.Is(err, os.ErrProcessDone) {
return fmt.Errorf("proc: 结束 %s: %w", p.name, err)
}
return nil
}