Files
HomeAgent/internal/plugin/proc/process.go
JianFeeeee d027c964e2 proc: Windows 共享内存 + 事件通知适配(Part 6.2 内核侧)
补齐内核侧的 Windows 创建端,与 6.1 的插件侧打开端配对。三平台
(linux/darwin/windows)现在都能构建 internal/plugin/proc。

## Windows 走命名内核对象(无 fd 继承语义)

os/exec 的 ExtraFiles 在 Windows 实现里不被支持,故:
- shmalloc_windows.go:CreateFileMappingW(INVALID_HANDLE_VALUE + 命名 →
  系统页文件支撑的匿名段,不落盘)+ MapViewOfFile
- evtfd_windows.go:CreateEventW 命名 Event 对象 + SetEvent 通知
- shmpass_windows.go:把段名/对象名经环境变量注入子进程
  (HOMEAGENT_SHM_STAGE / HOMEAGENT_SHM_EVTRING / HOMEAGENT_EVT_EVENT)

名字带 PID + 递增序号:多个 homed 实例并存时不能撞名。

Event 与 eventfd 的语义差异:Event 是二元信号,多次 SetEvent 只对应一次
唤醒,不累积。不影响正确性——消费者被唤醒后按 readSeq 追 writeSeq 批量
drain,丢的是"唤醒次数"不是"事件";事件环本身就允许溢出丢弃并让消费者
知道丢了(dropped 计数),通知面从来不是可靠投递语义。

## 传递机制抽象为 shmpass_*.go

Plugin.Start 不再直接构造 ExtraFiles 列表,改为问 Host 要:
  Env:        p.host.procEnvForShm()        // Windows 返回段名,Unix 返回 nil
  ExtraFiles: p.host.procExtraFilesForShm() // Unix 返回 fd 列表,Windows 返回 nil

平台差异被收敛到这一对函数,Plugin/coreHandler/stage 全部平台无关。

## macOS pipe 生命周期修正

原实现只返回读端 fd,写端 *os.File 无人持有 → 可能被 GC 回收 →
读端收到 EOF 而非阻塞 → 消费循环变忙转。改为 pipePair 表同时持有两端,
evtfdClose 一并关闭。

## E2E 测试跟进模板拆分

模板从单文件拆成三个(主体 + unix/windows 挂载),测试需要一并落盘,
否则编译报 attachStageShm undefined。procRuntimeTemplates 表必须与
SDK 仓 proc_runtime.go 的 procRuntimeFiles 一致。

验证:三平台 go build ./internal/plugin/... 通过(gojieba 的 cgo 依赖
导致 internal/memory 在非 linux 失败,与本次无关);
go test -race ./internal/plugin/... 全绿,含 2 项真实模板 E2E。

Ref: docs/zh/架构迁移评估.md §9.2、docs/zh/plugin-migration-plan.md Part 6
2026-09-02 19:07:14 +08:00

506 lines
15 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 (
"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
// evtRingSize 是事件环段大小0 表示不支持事件环)。
evtRingSize 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
// EvtRingSize 是事件环段大小0 表示不支持事件环)。
EvtRingSize 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,
evtRingSize: opts.EvtRingSize,
}
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,
EvtRingSize: p.evtRingSize,
})
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
}