feat(plugin): entry 双通道分派 + 共享内存 stage 数据面(Part 1 + Part 4 核心)

Part 1 加载分派骨架(迁移可逐插件推进、随时回退的前提):
- dynamic.go: 新增 binEntry/skillEntry 常量 + entryKind 枚举 + classifyEntry/detectEntryKind
  manifest entry 优先级最高(改回 plugin.so 即回退 cabi);无 manifest 时按目录探测,.bin 优先
- registry.go: tryDynamic 按 entry 分派 proc/cabi 双通道;
  entry 声明 .bin 但二进制缺失时报明确错误,不静默回退(否则'已迁移插件跑回旧通道'极难排查)
- registry.go: pluginEntryHash 候选顺序与 detectEntryKind 对齐(.bin 优先),
  否则增量重载会用错文件算 hash
- dynamic_proc_{unix,windows}.go: tryLoadProc 桩位(权限/类型校验已实现,进程管理属 Part 2)

Part 4 共享内存数据面(迁移评估 §3.3/§3.4/§3.7,最关键一环):
- proc/shm.go: 段布局(Header + ShmStageCtx 描述符数组 + append-only arena)
  相对偏移设计——各进程 mmap 到不同虚拟地址仍能正确解引用
  arena 用尽显式报错而非静默截断(§4.4 风险登记);Compact() 回收 append-only 垃圾
- proc/shmcodec.go: StageContext 16 字段跨进程编解码
  字段级描述符消除 lost update:只改 FinalText 的插件不触碰 ToolResults 描述符
  WriteDirty 只写脏字段——只读插件零写入,不可能覆盖他人改写
  Snapshot 存序列化字符串(切片共享底层数组的坑,C ABI 侧修 11.3 时已踩过)
  Extra 4 键提升为具名字段;Response 用标志位表达 nil vs 空串
- proc/lock.go: 锁仲裁回归内核(§3.7 已裁定,零 cgo)
  ForceRelease 实现实验 9 的崩溃自愈——排除 robust pthread_mutex 必要性
  重复加锁显式拒绝(否则死锁 30s);等待超时有补偿 goroutine 防锁泄漏

验证:
- proc 包 16 项测试全绿(含 -race):全字段往返/只读零写回/原地改切片识别/
  现网 sanitizer+weather 场景/5插件×40轮并发零丢失/arena 耗尽报错/压实不破坏字段/
  锁互斥·串扰拒绝·崩溃自愈·临界区串行化
- entry 分派 9 项测试全绿;go build ./... exit 0;接口冻结 git diff sdk/ 为空
This commit is contained in:
dev
2026-09-02 10:41:18 +08:00
parent fe2fdc9692
commit 610e9d0bbb
12 changed files with 1919 additions and 8 deletions

View File

@ -7,12 +7,84 @@ import (
) )
const ( const (
soEntry = "plugin.so" soEntry = "plugin.so"
dllEntry = "plugin.dll" dllEntry = "plugin.dll"
luaEntry = "main.lua" binEntry = "plugin.bin" // 子进程插件(纯 Go 二进制stdio JSON-RPC
metaEntry = "plugin.json" luaEntry = "main.lua"
skillEntry = "SKILL.md"
metaEntry = "plugin.json"
) )
// entryKind 描述插件入口归属的加载通道。
// 外部插件多进程化期间 .so/.dllcabi与 .binproc**双通道共存**
// 按 plugin.json 的 entry 字段分派,使迁移可逐插件推进、随时回退。
type entryKind int
const (
entryUnknown entryKind = iota
entryCABI // plugin.so / plugin.dll / plugin.dylib —— C ABI 动态库
entryProc // plugin.bin —— 子进程 + stdio JSON-RPC
entryLua // main.lua
entrySkill // SKILL.md
)
func (k entryKind) String() string {
switch k {
case entryCABI:
return "cabi"
case entryProc:
return "proc"
case entryLua:
return "lua"
case entrySkill:
return "skill"
}
return "unknown"
}
// classifyEntry 把 manifest 的 entry 字段映射到加载通道。
// entry 为空时返回 entryUnknown由调用方回退到目录探测兼容无 manifest 的旧插件)。
func classifyEntry(entry string) entryKind {
switch entry {
case soEntry, dllEntry, "plugin.dylib":
return entryCABI
case binEntry:
return entryProc
case luaEntry:
return entryLua
case skillEntry:
return entrySkill
}
return entryUnknown
}
// detectEntryKind 先读 manifest 的 entry读不到则按目录内存在的入口文件推断。
// 推断顺序:.bin 优先于 .so——迁移期间同一插件目录可能两个产物共存升级未清理
// 此时应走新通道manifest 显式声明优先级最高。
func detectEntryKind(plgDir string) entryKind {
if mft := readManifest(plgDir); mft != nil {
if k := classifyEntry(mft.Entry); k != entryUnknown {
return k
}
}
for _, probe := range []struct {
file string
kind entryKind
}{
{binEntry, entryProc},
{soEntry, entryCABI},
{"plugin.dylib", entryCABI},
{dllEntry, entryCABI},
{luaEntry, entryLua},
{skillEntry, entrySkill},
} {
if st, err := os.Stat(filepath.Join(plgDir, probe.file)); err == nil && !st.IsDir() {
return probe.kind
}
}
return entryUnknown
}
func readManifest(dir string) *PluginManifest { func readManifest(dir string) *PluginManifest {
data, err := os.ReadFile(filepath.Join(dir, metaEntry)) data, err := os.ReadFile(filepath.Join(dir, metaEntry))
if err != nil { if err != nil {

View File

@ -0,0 +1,43 @@
//go:build linux || darwin
package plugin
import (
"fmt"
"os"
"path/filepath"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// tryLoadProc 加载子进程插件plugin.bin——外部插件多进程化的加载入口。
//
// 设计依据docs/zh/架构迁移评估.md §3stdio JSON-RPC 控制面 + shm 数据面 + eventfd 通知面)
// 实施计划docs/zh/plugin-migration-plan.md Part 2
//
// 当前状态:**分派桩位**。共享内存数据面与锁仲裁已在 internal/plugin/proc/ 落地
// 并通过 16 项测试(含 -race进程管理与 RPC 编解码为 Part 2 内容。
//
// 返回 nil,nil 表示目录中没有 plugin.bin交由后续探测通道
// 找到二进制但通道未就绪时返回明确错误——不静默回退到 cabi
// 否则"已迁移插件跑回旧通道"极难排查。
func tryLoadProc(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
binPath := filepath.Join(dir, binEntry)
st, err := os.Stat(binPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("proc plugin %s: 检查 %s: %w", name, binEntry, err)
}
if st.IsDir() {
return nil, fmt.Errorf("proc plugin %s: %s 是目录,不是可执行文件", name, binEntry)
}
if st.Mode()&0o111 == 0 {
return nil, fmt.Errorf("proc plugin %s: %s 缺少可执行权限chmod +x", name, binEntry)
}
return nil, fmt.Errorf("proc plugin %s: 子进程通道尚未实现Part 2——"+
"共享内存数据面已就绪internal/plugin/proc"+
"如需运行请把 plugin.json 的 entry 改回 %s 走 C ABI 通道", name, soEntry)
}

View File

@ -0,0 +1,25 @@
//go:build windows
package plugin
import (
"fmt"
"os"
"path/filepath"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// tryLoadProc 的 Windows 桩子进程通道本身是跨平台的stdio JSON-RPC 无平台差异),
// 但共享内存数据面当前基于 POSIX mmapWindows 需改用 CreateFileMapping。
//
// 迁移评估 §9.2 已记录Windows DLL 路径当前能力严重退化(只下发 3 字段、无写回),
// 迁移到子进程后三套 ABI 收敛为单一 RPC 实现Windows 反而受益——但需要测试机验证。
func tryLoadProc(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
for _, candidate := range []string{binEntry, "plugin.exe"} {
if st, err := os.Stat(filepath.Join(dir, candidate)); err == nil && !st.IsDir() {
return nil, fmt.Errorf("proc plugin %s: Windows 子进程通道尚未实现Part 2 + §9.2", name)
}
}
return nil, nil
}

View File

@ -0,0 +1,160 @@
package plugin
import (
"os"
"path/filepath"
"testing"
)
// entry 分派骨架docs/zh/plugin-migration-plan.md Part 1
// 外部插件多进程化期间 .so/.dllcabi与 .binproc双通道共存
// 按 plugin.json 的 entry 字段分派,使迁移可逐插件推进、随时回退。
func TestClassifyEntry(t *testing.T) {
cases := []struct {
entry string
want entryKind
}{
{"plugin.so", entryCABI},
{"plugin.dll", entryCABI},
{"plugin.dylib", entryCABI},
{"plugin.bin", entryProc},
{"main.lua", entryLua},
{"SKILL.md", entrySkill},
{"", entryUnknown},
{"plugin.wasm", entryUnknown},
}
for _, c := range cases {
if got := classifyEntry(c.entry); got != c.want {
t.Errorf("classifyEntry(%q) = %v, want %v", c.entry, got, c.want)
}
}
}
// manifest 显式声明的 entry 优先级最高。
func TestDetectEntryKind_ManifestWins(t *testing.T) {
dir := t.TempDir()
// 目录里放 .so但 manifest 声明 .bin → 应走 proc
mustWrite(t, filepath.Join(dir, "plugin.so"), "fake so")
mustWrite(t, filepath.Join(dir, "plugin.bin"), "fake bin")
mustWrite(t, filepath.Join(dir, metaEntry), `{"name":"x","entry":"plugin.bin"}`)
if got := detectEntryKind(dir); got != entryProc {
t.Fatalf("manifest 声明 plugin.bin 应走 proc实际 %v", got)
}
}
// manifest 声明 .so 时即便存在 .bin 也走 cabi —— 这是回退路径的保证。
func TestDetectEntryKind_ManifestCanForceRollback(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.so"), "fake so")
mustWrite(t, filepath.Join(dir, "plugin.bin"), "fake bin")
mustWrite(t, filepath.Join(dir, metaEntry), `{"name":"x","entry":"plugin.so"}`)
if got := detectEntryKind(dir); got != entryCABI {
t.Fatalf("manifest 声明 plugin.so 应回退到 cabi实际 %v", got)
}
}
// 无 manifest或 entry 为空)时按目录探测,.bin 优先于 .so
// 迁移期间同目录可能两种产物共存(升级未清理),此时应走新通道。
func TestDetectEntryKind_ProbeOrderPrefersBin(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.so"), "fake so")
mustWrite(t, filepath.Join(dir, "plugin.bin"), "fake bin")
if got := detectEntryKind(dir); got != entryProc {
t.Fatalf("无 manifest 时应优先 plugin.bin实际 %v", got)
}
}
func TestDetectEntryKind_ProbeFallbacks(t *testing.T) {
t.Run("only so", func(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.so"), "x")
if got := detectEntryKind(dir); got != entryCABI {
t.Fatalf("got %v", got)
}
})
t.Run("only lua", func(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "main.lua"), "x")
if got := detectEntryKind(dir); got != entryLua {
t.Fatalf("got %v", got)
}
})
t.Run("only skill", func(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "SKILL.md"), "x")
if got := detectEntryKind(dir); got != entrySkill {
t.Fatalf("got %v", got)
}
})
t.Run("empty dir", func(t *testing.T) {
if got := detectEntryKind(t.TempDir()); got != entryUnknown {
t.Fatalf("空目录应为 unknown实际 %v", got)
}
})
}
// entry 声明 plugin.bin 但二进制缺失时必须报明确错误,
// 不得静默回退到 cabi —— 否则"已迁移插件跑回旧通道"极难排查。
func TestTryLoadProc_MissingBinaryReturnsNil(t *testing.T) {
dir := t.TempDir()
plg, err := tryLoadProc(dir, "demo", nil)
if plg != nil || err != nil {
t.Fatalf("无 plugin.bin 应返回 nil,nil交由后续探测实际 plg=%v err=%v", plg, err)
}
}
func TestTryLoadProc_NonExecutableRejected(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, binEntry)
mustWrite(t, path, "not executable")
if err := os.Chmod(path, 0o644); err != nil {
t.Fatalf("chmod: %v", err)
}
_, err := tryLoadProc(dir, "demo", nil)
if err == nil {
t.Fatal("缺少可执行权限应报错")
}
}
// pluginEntryHash 的候选顺序须与 detectEntryKind 一致plugin.bin 优先),
// 否则增量重载会用错文件算 hash导致"换了 .bin 但内核以为没变"。
func TestPluginEntryHash_PrefersBin(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.so"), "so content")
mustWrite(t, filepath.Join(dir, "plugin.bin"), "bin content")
h1 := pluginEntryHash(dir)
if h1 == "" {
t.Fatal("应算出 hash")
}
// 改 .so 不应影响 hash因为以 .bin 为准)
mustWrite(t, filepath.Join(dir, "plugin.so"), "so content CHANGED")
if h2 := pluginEntryHash(dir); h2 != h1 {
t.Error("plugin.bin 存在时 hash 不应受 plugin.so 变化影响")
}
// 改 .bin 必须改变 hash
mustWrite(t, filepath.Join(dir, "plugin.bin"), "bin content CHANGED")
if h3 := pluginEntryHash(dir); h3 == h1 {
t.Error("plugin.bin 变化必须反映到 hash否则增量重载失效")
}
}
func TestPluginEntryHash_EmptyForFactoryOnlyPlugin(t *testing.T) {
if h := pluginEntryHash(t.TempDir()); h != "" {
t.Errorf("无入口文件应返回空串(内置纯工厂插件),实际 %q", h)
}
}
func mustWrite(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
t.Fatalf("写 %s: %v", path, err)
}
}

View File

@ -19,7 +19,7 @@ type PluginManifest struct {
License string `json:"license,omitempty"` License string `json:"license,omitempty"`
Homepage string `json:"homepage,omitempty"` Homepage string `json:"homepage,omitempty"`
Repository string `json:"repository,omitempty"` Repository string `json:"repository,omitempty"`
Entry string `json:"entry"` // "plugin.so" | "plugin.dll" | "main.lua" | "SKILL.md" Entry string `json:"entry"` // "plugin.bin"(子进程) | "plugin.so" | "plugin.dll" | "main.lua" | "SKILL.md"
Platforms []string `json:"platforms,omitempty"` // 声明的支持平台: ["linux","darwin","windows"] Platforms []string `json:"platforms,omitempty"` // 声明的支持平台: ["linux","darwin","windows"]
MinVersion string `json:"min_version,omitempty"` MinVersion string `json:"min_version,omitempty"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`

View File

@ -0,0 +1,131 @@
package proc
import (
"fmt"
"sync"
"time"
)
// 跨进程锁锁仲裁回归内核§3.7 已裁定,实验 3 + 实验 9 支撑)。
//
// 为什么不用 robust pthread_mutex
// - PTHREAD_PROCESS_SHARED + ROBUST 属性 Go 标准库无等价物,引入它意味着
// **为了一把锁保留 cgo**——与"C 整体退场"的目标冲突。
// - 锁仲裁回内核后,持锁进程崩溃由 cmd.Wait()/stdio EOF 检测,内核代为释放;
// 实验 9 实测无死锁、**无需 EOWNERDEAD 处理**。
// - 成本:一次 RPC 往返 19.4 µs实验 320000 次测得。stage handler 的加锁
// 频率很低(每次 stage 一两次,不是每字段一次),微秒级往返可忽略。
//
// 于是整个新架构可做到**完全无 cgo**。
// lockWaitTimeout 是插件申请 stage 锁的最长等待时间。
//
// 取 30sstage handler 自身受 60s 工具超时约束toolcall.go锁等待
// 必须显著短于它,否则超时错误会指向错误的原因。超时返回错误而非
// 静默继续——**持锁失败下改写共享段会破坏并发正确性**。
const lockWaitTimeout = 30 * time.Second
// stageLock 是内核侧为单个 stage 执行持有的互斥体。
//
// 一次 RunStage 对应一个 stageLock 实例:同阶段并发扇出的所有插件
// (含跨进程的)在此排队,语义等价于今日内置插件共享
// *StageContext 的 sync.RWMutex——这正是"保留并发扇出原始设计"
// §0.2 第 1 条:并发扇出是原始设计,不是缺陷)。
type stageLock struct {
mu sync.Mutex
// ownerMu 保护 owner/held使 ForceRelease 能安全介入
ownerMu sync.Mutex
owner string // 当前持锁的插件名,空表示未持有
held bool
}
func newStageLock() *stageLock { return &stageLock{} }
// Acquire 为 plugin 申请写锁,带超时。
//
// 同一插件重复 Acquire 会死锁stage handler 不应嵌套加锁),
// 故显式拒绝并返回错误——比让插件挂死 30s 更容易排查。
func (l *stageLock) Acquire(plugin string) error {
l.ownerMu.Lock()
if l.held && l.owner == plugin {
l.ownerMu.Unlock()
return fmt.Errorf("proc: 插件 %s 重复申请 stage 锁handler 内不应嵌套加锁)", plugin)
}
l.ownerMu.Unlock()
acquired := make(chan struct{})
go func() {
l.mu.Lock()
close(acquired)
}()
select {
case <-acquired:
l.ownerMu.Lock()
l.owner = plugin
l.held = true
l.ownerMu.Unlock()
return nil
case <-time.After(lockWaitTimeout):
// 等待超时:上面的 goroutine 可能随后拿到锁,必须让它能释放,
// 否则锁永久泄漏。用一个补偿 goroutine 等它拿到后立刻放掉。
go func() {
<-acquired
l.ownerMu.Lock()
stillFree := !l.held
l.ownerMu.Unlock()
if stillFree {
l.mu.Unlock()
}
}()
return fmt.Errorf("proc: 插件 %s 申请 stage 锁超时(%s", plugin, lockWaitTimeout)
}
}
// Release 释放写锁。非持锁者调用返回错误(防止串扰)。
func (l *stageLock) Release(plugin string) error {
l.ownerMu.Lock()
if !l.held {
l.ownerMu.Unlock()
return fmt.Errorf("proc: 插件 %s 释放未持有的 stage 锁", plugin)
}
if l.owner != plugin {
owner := l.owner
l.ownerMu.Unlock()
return fmt.Errorf("proc: 插件 %s 试图释放 %s 持有的 stage 锁", plugin, owner)
}
l.owner = ""
l.held = false
l.ownerMu.Unlock()
l.mu.Unlock()
return nil
}
// ForceRelease 在插件进程崩溃/退出时由内核代为释放其持有的锁(实验 9 的自愈机制)。
//
// 返回是否实际释放了锁。**这是"无需 robust mutex"的核心**
// 锁的所有权在内核进程,插件死亡由 cmd.Wait()/stdio EOF 检测到,
// 内核直接解锁,不存在"持锁者死亡导致全局死锁"。
func (l *stageLock) ForceRelease(plugin string) bool {
l.ownerMu.Lock()
if !l.held || l.owner != plugin {
l.ownerMu.Unlock()
return false
}
l.owner = ""
l.held = false
l.ownerMu.Unlock()
l.mu.Unlock()
return true
}
// Owner 返回当前持锁插件名(诊断用)。
func (l *stageLock) Owner() string {
l.ownerMu.Lock()
defer l.ownerMu.Unlock()
if !l.held {
return ""
}
return l.owner
}

View File

@ -0,0 +1,184 @@
package proc
import (
"strings"
"sync"
"testing"
"time"
)
// 锁仲裁回归内核§3.7 已裁定)的行为验证,含实验 9 的崩溃自愈机制。
func TestStageLock_MutualExclusion(t *testing.T) {
l := newStageLock()
if err := l.Acquire("A"); err != nil {
t.Fatalf("A 应能获得锁: %v", err)
}
if l.Owner() != "A" {
t.Errorf("Owner 应为 A实际 %q", l.Owner())
}
// B 在 A 持锁期间不得进入
entered := make(chan struct{})
go func() {
_ = l.Acquire("B")
close(entered)
}()
select {
case <-entered:
t.Fatal("A 持锁期间 B 不应获得锁")
case <-time.After(50 * time.Millisecond):
}
if err := l.Release("A"); err != nil {
t.Fatalf("A 释放失败: %v", err)
}
select {
case <-entered:
case <-time.After(2 * time.Second):
t.Fatal("A 释放后 B 应获得锁")
}
if l.Owner() != "B" {
t.Errorf("Owner 应为 B实际 %q", l.Owner())
}
_ = l.Release("B")
}
// 非持锁者不得释放他人的锁(防止串扰导致并发正确性被破坏)。
func TestStageLock_ReleaseByNonOwnerRejected(t *testing.T) {
l := newStageLock()
if err := l.Acquire("A"); err != nil {
t.Fatalf("Acquire: %v", err)
}
defer l.Release("A")
err := l.Release("B")
if err == nil {
t.Fatal("非持锁者释放应被拒绝")
}
if !strings.Contains(err.Error(), "试图释放") {
t.Errorf("错误信息应说明串扰,实际: %v", err)
}
if l.Owner() != "A" {
t.Errorf("A 应仍持锁,实际 owner=%q", l.Owner())
}
}
func TestStageLock_ReleaseWithoutHoldRejected(t *testing.T) {
l := newStageLock()
if err := l.Release("A"); err == nil {
t.Fatal("未持锁时释放应报错")
}
}
// handler 内嵌套加锁会死锁,应显式拒绝而不是让插件挂死到超时。
func TestStageLock_ReentrantAcquireRejected(t *testing.T) {
l := newStageLock()
if err := l.Acquire("A"); err != nil {
t.Fatalf("Acquire: %v", err)
}
defer l.Release("A")
err := l.Acquire("A")
if err == nil {
t.Fatal("同一插件重复加锁应被拒绝(否则死锁 30s")
}
if !strings.Contains(err.Error(), "重复申请") {
t.Errorf("错误信息应说明重复加锁,实际: %v", err)
}
}
// 实验 9 的核心:持锁进程崩溃后内核代为释放,后续插件不死锁。
// 这条彻底排除了 robust pthread_mutex 的必要性 —— 整个架构零 cgo。
func TestStageLock_ForceReleaseOnPluginCrash(t *testing.T) {
l := newStageLock()
// 插件 X 拿锁后"崩溃"(不调用 Release
if err := l.Acquire("X"); err != nil {
t.Fatalf("X Acquire: %v", err)
}
if !l.ForceRelease("X") {
t.Fatal("内核应能强制释放崩溃插件持有的锁")
}
if l.Owner() != "" {
t.Errorf("强制释放后应无持有者,实际 %q", l.Owner())
}
// 插件 Y 随后必须能正常拿到锁(无死锁)
done := make(chan error, 1)
go func() { done <- l.Acquire("Y") }()
select {
case err := <-done:
if err != nil {
t.Fatalf("Y 应能获得锁: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("X 崩溃后 Y 无法获得锁 —— 出现死锁")
}
if err := l.Release("Y"); err != nil {
t.Fatalf("Y 释放失败: %v", err)
}
}
// ForceRelease 对非持有者/未持锁应为 no-op不能误放他人的锁。
func TestStageLock_ForceReleaseIsTargeted(t *testing.T) {
l := newStageLock()
if err := l.Acquire("A"); err != nil {
t.Fatalf("Acquire: %v", err)
}
defer l.Release("A")
if l.ForceRelease("B") {
t.Error("强制释放不该动 A 持有的锁")
}
if l.Owner() != "A" {
t.Errorf("A 应仍持锁,实际 %q", l.Owner())
}
}
// 高并发下锁的串行化保证:临界区不重叠。
func TestStageLock_SerializesCriticalSection(t *testing.T) {
l := newStageLock()
var (
mu sync.Mutex
inside int
maxSeen int
)
const workers = 8
const iters = 50
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
name := string(rune('A' + id))
for j := 0; j < iters; j++ {
if err := l.Acquire(name); err != nil {
t.Errorf("Acquire: %v", err)
return
}
mu.Lock()
inside++
if inside > maxSeen {
maxSeen = inside
}
mu.Unlock()
mu.Lock()
inside--
mu.Unlock()
if err := l.Release(name); err != nil {
t.Errorf("Release: %v", err)
return
}
}
}(i)
}
wg.Wait()
if maxSeen > 1 {
t.Fatalf("临界区出现并发:同时 %d 个持有者", maxSeen)
}
}

307
internal/plugin/proc/shm.go Normal file
View File

@ -0,0 +1,307 @@
// Package proc 实现外部插件的子进程加载通道plugin.bin
//
// 设计依据docs/zh/架构迁移评估.md 第三章
//
// homed ──spawn──> plugin纯 Go 二进制,无 cgo
// ├── stdio JSON-RPC 控制面51 个 method id 平移为 method 名§3.2
// ├── shm + 偏移 数据面StageContext 并发改写、二进制零拷贝§3.3
// └── eventfd 通知面:事件环 post-and-forget§3.6
//
// 本文件负责数据面的共享段布局与 arena 分配器。
package proc
import (
"encoding/binary"
"fmt"
"sync/atomic"
)
// 共享段魔数与版本,用于挂载时校验对端布局一致。
const (
shmMagic uint32 = 0x48415348 // "HASH" — HomeAgent SHared
shmVersion uint32 = 1
)
// 段布局(所有偏移均相对**段起始**arena 内偏移相对 arenaBase
//
// [0, headerSize) Header魔数/版本/arena 游标
// [headerSize, ctxEnd) ShmStageCtx每字段一个 Slice{off,len} 描述符
// [arenaBase, arenaBase+arenaCap) arenaappend-only 变长数据区
//
// **相对偏移是关键**§3.3)——各进程 mmap 到不同虚拟地址仍能正确解引用。
const (
headerSize = 64
// Header 内字段偏移
offMagic = 0 // uint32
offVersion = 4 // uint32
offArenaBase = 8 // uint32
offArenaCap = 12 // uint32
offArenaUsed = 16 // uint32原子 bump 游标)
offCtxBase = 20 // uint32
offSeq = 24 // uint64每次成功写回自增供乐观读校验
)
// Slice 是 arena 内变长数据的描述符off 相对 arenaBase。
// 长度为 0 表示空值off==0 && len==0 表示"字段未设置"。
type Slice struct {
Off uint32
Len uint32
}
const sliceSize = 8
// IsUnset 报告该描述符是否表示"字段从未被写入"。
// 注意与"写入了空字符串"区分:后者 Off 非 0、Len 为 0。
func (s Slice) IsUnset() bool { return s.Off == 0 && s.Len == 0 }
// stageField 枚举 StageContext 的 16 个字段在共享段中的槽位。
//
// **字段级粒度是消除 lost update 的机制**:每个字段独立一个 Slice 描述符,
// 只改 FinalText 的插件完全不触碰 ToolResults 的描述符,因此不存在
// "只读插件把自己收到的旧快照写回、覆盖他人改写"的问题(对比今日副本模型
// 实测 35.8~36.8% 丢失率,见 §8.4)。
//
// 字段内部的编码方式(原始字符串 vs JSON不影响这一性质
// ToolCall.Arguments 是 map[string]interface{}、ToolResult.Result 是 interface{}
// 无法拆成定长结构,故以 JSON 存入 arena——工具结果中位数仅 93B§2.5
// 序列化开销占 LLM 往返的 0.0001%,不构成瓶颈。
type stageField int
const (
fRawMessage stageField = iota
fUserID
fGroupID
fLLMText
fReasoningContent
fFinalText
fResponse // 配合 fResponseSet 表达 *string 的 nil 语义
fPhase
fContextMsgs // JSON
fToolCalls // JSON
fToolResults // JSON
fMemory // JSON
fTokenUsage // JSON
fErrors // JSON
fExtraMediaBlocks // JSON —— Extra 的 4 个键提升为具名字段§3.3 已核实使用点)
fExtraMediaType
fExtraInputSource
fExtraOutputChannel
stageFieldCount
)
// 标志位区(紧跟描述符数组):表达 bool 与指针的 nil 语义。
const (
flagNoMemory = 0
flagResponseSet = 1
flagCount = 8 // 预留到 8 字节,便于对齐与后续扩展
)
// ctxSize 是 ShmStageCtx 区域的总字节数。
const ctxSize = int(stageFieldCount)*sliceSize + flagCount
// Segment 是一块已 mmap 的共享段,内核与插件进程各持一个实例
// (底层同一物理页,虚拟地址可不同)。
type Segment struct {
data []byte // 完整 mmap 区域
}
// NewSegment 在给定的 mmap 区域上初始化段布局(内核侧调用一次)。
func NewSegment(data []byte) (*Segment, error) {
if len(data) < headerSize+ctxSize+1 {
return nil, fmt.Errorf("proc: 共享段过小(%d 字节,至少需要 %d",
len(data), headerSize+ctxSize+1)
}
s := &Segment{data: data}
arenaBase := uint32(headerSize + ctxSize)
arenaCap := uint32(len(data)) - arenaBase
binary.LittleEndian.PutUint32(data[offMagic:], shmMagic)
binary.LittleEndian.PutUint32(data[offVersion:], shmVersion)
binary.LittleEndian.PutUint32(data[offArenaBase:], arenaBase)
binary.LittleEndian.PutUint32(data[offArenaCap:], arenaCap)
binary.LittleEndian.PutUint32(data[offArenaUsed:], 0)
binary.LittleEndian.PutUint32(data[offCtxBase:], headerSize)
binary.LittleEndian.PutUint64(data[offSeq:], 0)
// 描述符与标志位清零IsUnset 语义依赖此)
for i := headerSize; i < headerSize+ctxSize; i++ {
data[i] = 0
}
return s, nil
}
// AttachSegment 挂载一块已由 NewSegment 初始化的区域(插件进程侧调用)。
// 校验魔数与版本,避免版本不一致时静默错读。
func AttachSegment(data []byte) (*Segment, error) {
if len(data) < headerSize+ctxSize {
return nil, fmt.Errorf("proc: 共享段过小(%d 字节)", len(data))
}
if got := binary.LittleEndian.Uint32(data[offMagic:]); got != shmMagic {
return nil, fmt.Errorf("proc: 共享段魔数不匹配0x%x期望 0x%x", got, shmMagic)
}
if got := binary.LittleEndian.Uint32(data[offVersion:]); got != shmVersion {
return nil, fmt.Errorf("proc: 共享段版本不匹配(%d本内核 %d——插件需用配套 plugindev 重编",
got, shmVersion)
}
return &Segment{data: data}, nil
}
func (s *Segment) arenaBase() uint32 { return binary.LittleEndian.Uint32(s.data[offArenaBase:]) }
func (s *Segment) arenaCap() uint32 { return binary.LittleEndian.Uint32(s.data[offArenaCap:]) }
func (s *Segment) ctxBase() uint32 { return binary.LittleEndian.Uint32(s.data[offCtxBase:]) }
// Seq 返回当前世代号。每次 WriteBack 成功后自增供乐观读校验§3.3)。
func (s *Segment) Seq() uint64 {
return atomic.LoadUint64((*uint64)(ptrU64(s.data[offSeq:])))
}
func (s *Segment) bumpSeq() { atomic.AddUint64((*uint64)(ptrU64(s.data[offSeq:])), 1) }
// ArenaUsed 返回 arena 已用字节数(诊断/压实判断用)。
func (s *Segment) ArenaUsed() uint32 {
return atomic.LoadUint32((*uint32)(ptrU32(s.data[offArenaUsed:])))
}
// ArenaCap 返回 arena 容量。
func (s *Segment) ArenaCap() uint32 { return s.arenaCap() }
// alloc 在 arena 上分配 n 字节并返回相对 arenaBase 的偏移。
//
// **append-only§3.3**:插件把 FinalText 从 10 字节改成 10KB 时分配新区域、
// 更新描述符旧区域留作垃圾arena 用尽由内核在 stage 结束后(此时无插件持锁)
// 整体压实。代价是单次 stage 内写入总量有上限——**上限必须显式报错而非静默截断**
// §4.4 风险登记)。
//
// 调用方须持有 stage 写锁(锁仲裁见 lock.go故这里用非原子的读-改-写即可;
// 仍用原子操作是为了让未持锁的诊断读取ArenaUsed不产生数据竞争。
func (s *Segment) alloc(n int) (uint32, error) {
if n < 0 {
return 0, fmt.Errorf("proc: 非法分配长度 %d", n)
}
// 偏移 0 保留给"字段未设置"语义,故 arena 从 1 开始分配。
used := s.ArenaUsed()
if used == 0 {
used = 1
}
end := uint64(used) + uint64(n)
if end > uint64(s.arenaCap()) {
return 0, fmt.Errorf("proc: arena 空间不足——需要 %d 字节,剩余 %d 字节(容量 %d已用 %d"+
"单次 stage 写入总量超限,请减少写入或等待内核压实",
n, int64(s.arenaCap())-int64(used), s.arenaCap(), used)
}
atomic.StoreUint32((*uint32)(ptrU32(s.data[offArenaUsed:])), uint32(end))
return used, nil
}
// write 把 b 写入 arena 并返回描述符。空切片返回 {Off:1, Len:0}
// (非 IsUnset —— 表达"写入了空值",与"未设置"区分)。
func (s *Segment) write(b []byte) (Slice, error) {
if len(b) == 0 {
return Slice{Off: 1, Len: 0}, nil
}
off, err := s.alloc(len(b))
if err != nil {
return Slice{}, err
}
base := s.arenaBase()
copy(s.data[base+off:base+off+uint32(len(b))], b)
return Slice{Off: off, Len: uint32(len(b))}, nil
}
// read 按描述符取出 arena 中的字节(返回的是段内切片视图,调用方须在持锁期间使用)。
func (s *Segment) read(sl Slice) ([]byte, error) {
if sl.IsUnset() || sl.Len == 0 {
return nil, nil
}
base := s.arenaBase()
if uint64(sl.Off)+uint64(sl.Len) > uint64(s.arenaCap()) {
return nil, fmt.Errorf("proc: 描述符越界off=%d len=%d cap=%d", sl.Off, sl.Len, s.arenaCap())
}
return s.data[base+sl.Off : base+sl.Off+sl.Len], nil
}
// descOffset 返回字段 f 的描述符在段内的绝对偏移。
func (s *Segment) descOffset(f stageField) uint32 {
return s.ctxBase() + uint32(int(f)*sliceSize)
}
func (s *Segment) getDesc(f stageField) Slice {
o := s.descOffset(f)
return Slice{
Off: binary.LittleEndian.Uint32(s.data[o:]),
Len: binary.LittleEndian.Uint32(s.data[o+4:]),
}
}
func (s *Segment) setDesc(f stageField, sl Slice) {
o := s.descOffset(f)
binary.LittleEndian.PutUint32(s.data[o:], sl.Off)
binary.LittleEndian.PutUint32(s.data[o+4:], sl.Len)
}
func (s *Segment) flagsOffset() uint32 {
return s.ctxBase() + uint32(int(stageFieldCount)*sliceSize)
}
func (s *Segment) getFlag(bit int) bool {
return s.data[s.flagsOffset()+uint32(bit)] != 0
}
func (s *Segment) setFlag(bit int, v bool) {
b := byte(0)
if v {
b = 1
}
s.data[s.flagsOffset()+uint32(bit)] = b
}
// Compact 回收 arena 垃圾:把仍被描述符引用的数据紧凑重排到段头部。
//
// 必须在**无插件持锁**时调用§3.3:由内核在 stage 结束后执行)。
// 返回回收的字节数。
func (s *Segment) Compact() uint32 {
before := s.ArenaUsed()
// 收集现存描述符指向的数据,按字段顺序重新写入。
type kept struct {
f stageField
data []byte
}
var live []kept
for f := stageField(0); f < stageFieldCount; f++ {
sl := s.getDesc(f)
if sl.IsUnset() {
continue
}
b, err := s.read(sl)
if err != nil {
// 描述符损坏:丢弃该字段而非让压实失败(诊断由上层日志承担)
s.setDesc(f, Slice{})
continue
}
cp := make([]byte, len(b))
copy(cp, b)
live = append(live, kept{f: f, data: cp})
}
// 重置游标后按序回填
atomic.StoreUint32((*uint32)(ptrU32(s.data[offArenaUsed:])), 0)
for _, k := range live {
sl, err := s.write(k.data)
if err != nil {
// 压实后仍放不下:理论上不可能(总量未增),保守清空该字段
s.setDesc(k.f, Slice{})
continue
}
s.setDesc(k.f, sl)
}
after := s.ArenaUsed()
if before > after {
return before - after
}
return 0
}

View File

@ -0,0 +1,441 @@
package proc
import (
"fmt"
"strings"
"sync"
"testing"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// 本文件验证共享内存 stage 并发的正确性——**整个迁移最关键的一环**§4.4 风险 3.4)。
//
// 对照基线(今日 C ABI 副本模型):
// - 内置插件(共享 *StageContext + RWMutex0% 丢失
// - 外部插件(快照-副本-写回35.8~36.8% 丢失(实验 12现网量级百分之几脏数据
//
// 目标:共享内存 + 锁仲裁下,跨进程并发改写收敛到 0% 丢失。
// newTestSegment 造一块内存段模拟 mmap 区域(单测无需真 mmap
// 编解码与 arena 逻辑与底层是 mmap 还是普通内存无关)。
func newTestSegment(t *testing.T, size int) *Segment {
t.Helper()
buf := make([]byte, size)
seg, err := NewSegment(buf)
if err != nil {
t.Fatalf("NewSegment: %v", err)
}
return seg
}
func TestSegment_AttachValidatesMagicAndVersion(t *testing.T) {
buf := make([]byte, 8192)
if _, err := NewSegment(buf); err != nil {
t.Fatalf("NewSegment: %v", err)
}
if _, err := AttachSegment(buf); err != nil {
t.Fatalf("AttachSegment 应成功: %v", err)
}
// 魔数损坏
bad := make([]byte, len(buf))
copy(bad, buf)
bad[0] ^= 0xFF
if _, err := AttachSegment(bad); err == nil {
t.Error("魔数不匹配应报错(避免版本不一致时静默错读)")
}
// 版本不匹配
badVer := make([]byte, len(buf))
copy(badVer, buf)
badVer[4] = 99
if _, err := AttachSegment(badVer); err == nil {
t.Error("版本不匹配应报错")
}
}
// 全部 16 个字段可跨进程往返——今日经 C ABI 只有 10 个字段可见§8.3)。
func TestSegment_RoundTripAllFields(t *testing.T) {
seg := newTestSegment(t, 16384)
resp := "短路响应"
src := &pubsdk.StageContext{
RawMessage: "原始输入",
UserID: "u1",
GroupID: "g1",
LLMText: "模型输出",
ReasoningContent: "思考过程", // C ABI 下外部插件看不到
FinalText: "最终文本",
Response: &resp,
Phase: pubsdk.StageAfterToolcall,
NoMemory: true,
ContextMsgs: []map[string]interface{}{{"role": "user", "content": "hi"}}, // C ABI 看不到
ToolCalls: []pubsdk.ToolCall{{ID: "t1", Name: "weather_query", Plugin: "weather"}},
ToolResults: []pubsdk.ToolResult{{CallID: "t1", Name: "weather_query", Success: true, Result: "晴"}},
Memory: []pubsdk.MemItem{{Role: "user", Content: "记忆", Score: 0.9}}, // C ABI 看不到
TokenUsage: map[string]int{"prompt": 100, "completion": 50}, // C ABI 看不到
Errors: []string{"err1"}, // C ABI 看不到
Extra: map[string]interface{}{
ExtraKeyMediaType: "image",
ExtraKeyInputSource: "qq",
ExtraKeyOutputChannel: "qq",
},
}
if err := seg.WriteAll(src); err != nil {
t.Fatalf("WriteAll: %v", err)
}
var dst pubsdk.StageContext
if err := seg.ReadInto(&dst); err != nil {
t.Fatalf("ReadInto: %v", err)
}
if dst.RawMessage != src.RawMessage || dst.UserID != src.UserID || dst.GroupID != src.GroupID {
t.Errorf("标量字段不一致: raw=%q uid=%q gid=%q", dst.RawMessage, dst.UserID, dst.GroupID)
}
if dst.ReasoningContent != "思考过程" {
t.Errorf("ReasoningContent 应可见C ABI 下不可见): %q", dst.ReasoningContent)
}
if len(dst.ContextMsgs) != 1 {
t.Errorf("ContextMsgs 应可见: %v", dst.ContextMsgs)
}
if len(dst.Memory) != 1 || dst.Memory[0].Score != 0.9 {
t.Errorf("Memory 应可见: %v", dst.Memory)
}
if dst.TokenUsage["prompt"] != 100 {
t.Errorf("TokenUsage 应可见: %v", dst.TokenUsage)
}
if len(dst.Errors) != 1 {
t.Errorf("Errors 应可见: %v", dst.Errors)
}
if dst.Response == nil || *dst.Response != resp {
t.Errorf("Response 应往返: %v", dst.Response)
}
if !dst.NoMemory {
t.Error("NoMemory 标志应往返")
}
if len(dst.ToolResults) != 1 || dst.ToolResults[0].Result != "晴" {
t.Errorf("ToolResults 应往返: %v", dst.ToolResults)
}
if dst.Extra[ExtraKeyMediaType] != "image" {
t.Errorf("Extra 提升字段应往返: %v", dst.Extra)
}
}
// nil Response 与空字符串 Response 必须可区分(短路语义依赖此)。
func TestSegment_ResponseNilVsEmpty(t *testing.T) {
seg := newTestSegment(t, 8192)
if err := seg.WriteAll(&pubsdk.StageContext{RawMessage: "x"}); err != nil {
t.Fatalf("WriteAll: %v", err)
}
var d1 pubsdk.StageContext
if err := seg.ReadInto(&d1); err != nil {
t.Fatalf("ReadInto: %v", err)
}
if d1.Response != nil {
t.Errorf("未设置的 Response 应为 nil实际 %q", *d1.Response)
}
empty := ""
seg2 := newTestSegment(t, 8192)
if err := seg2.WriteAll(&pubsdk.StageContext{Response: &empty}); err != nil {
t.Fatalf("WriteAll: %v", err)
}
var d2 pubsdk.StageContext
if err := seg2.ReadInto(&d2); err != nil {
t.Fatalf("ReadInto: %v", err)
}
if d2.Response == nil {
t.Error("显式设为空串的 Response 不应读成 nil短路语义会丢")
} else if *d2.Response != "" {
t.Errorf("Response 应为空串,实际 %q", *d2.Response)
}
}
// 只读插件的 WriteDirty 必须零写入——**这是消除 lost update 的核心断言**。
func TestSegment_WriteDirty_ReadOnlyPluginWritesNothing(t *testing.T) {
seg := newTestSegment(t, 16384)
base := &pubsdk.StageContext{
RawMessage: "查天气",
ToolResults: []pubsdk.ToolResult{{CallID: "c1", Name: "weather_query", Result: "已清洗"}},
}
if err := seg.WriteAll(base); err != nil {
t.Fatalf("WriteAll: %v", err)
}
// 插件侧:读入 → 只读 → 写回
var local pubsdk.StageContext
if err := seg.ReadInto(&local); err != nil {
t.Fatalf("ReadInto: %v", err)
}
snap := TakeSnapshot(&local)
_ = local.ToolResults[0].Result // 只读,不改
n, err := seg.WriteDirty(&local, snap)
if err != nil {
t.Fatalf("WriteDirty: %v", err)
}
if n != 0 {
t.Fatalf("只读插件应零写回,实际写回 %d 个字段(会覆盖他人改写)", n)
}
}
// 原地改切片元素必须被识别为脏 —— C ABI 侧修 11.3 时踩过的坑。
func TestSegment_WriteDirty_InPlaceSliceMutationDetected(t *testing.T) {
seg := newTestSegment(t, 16384)
if err := seg.WriteAll(&pubsdk.StageContext{
ToolResults: []pubsdk.ToolResult{{CallID: "c1", Result: "带\x1b[31mANSI\x1b[0m"}},
}); err != nil {
t.Fatalf("WriteAll: %v", err)
}
var local pubsdk.StageContext
if err := seg.ReadInto(&local); err != nil {
t.Fatalf("ReadInto: %v", err)
}
snap := TakeSnapshot(&local)
local.ToolResults[0].Result = "带ANSI" // 原地改元素sanitizer 的实际行为)
n, err := seg.WriteDirty(&local, snap)
if err != nil {
t.Fatalf("WriteDirty: %v", err)
}
if n != 1 {
t.Fatalf("原地改切片元素应被识别为 1 个脏字段,实际 %d", n)
}
var after pubsdk.StageContext
if err := seg.ReadInto(&after); err != nil {
t.Fatalf("ReadInto: %v", err)
}
if after.ToolResults[0].Result != "带ANSI" {
t.Errorf("清洗结果未写回: %v", after.ToolResults[0].Result)
}
}
// 复刻现网场景(实验 13sanitizer 改写 + weather 只读并发,清洗结果不得被覆盖。
// 这是 C ABI 副本模型下量级百分之几脏数据的直接来源。
func TestSegment_ProductionScenario_SanitizerNotOverwrittenByWeather(t *testing.T) {
seg := newTestSegment(t, 16384)
dirty := "天气:晴 \x1b[31m28°C\x1b[0m"
clean := "天气:晴 28°C"
if err := seg.WriteAll(&pubsdk.StageContext{
RawMessage: "查天气",
Phase: pubsdk.StageAfterToolcall,
ToolResults: []pubsdk.ToolResult{{CallID: "c1", Name: "weather_query", Result: dirty}},
}); err != nil {
t.Fatalf("WriteAll: %v", err)
}
lock := newStageLock()
// sanitizer拿锁 → 读 → 清洗 → 写脏字段 → 放锁
runSanitizer := func() error {
if err := lock.Acquire("sanitizer"); err != nil {
return err
}
defer lock.Release("sanitizer")
var local pubsdk.StageContext
if err := seg.ReadInto(&local); err != nil {
return err
}
snap := TakeSnapshot(&local)
if len(local.ToolResults) > 0 {
s, _ := local.ToolResults[0].Result.(string)
local.ToolResults[0].Result = strings.NewReplacer("\x1b[31m", "", "\x1b[0m", "").Replace(s)
}
_, err := seg.WriteDirty(&local, snap)
return err
}
// weather拿锁 → 读 → 只读 → 零写回 → 放锁
runWeather := func() error {
if err := lock.Acquire("weather"); err != nil {
return err
}
defer lock.Release("weather")
var local pubsdk.StageContext
if err := seg.ReadInto(&local); err != nil {
return err
}
snap := TakeSnapshot(&local)
if len(local.ToolResults) > 0 {
_ = local.ToolResults[0].Result // 只读
}
n, err := seg.WriteDirty(&local, snap)
if err != nil {
return err
}
if n != 0 {
return fmt.Errorf("weather 只读却写回 %d 个字段", n)
}
return nil
}
// 并发扇出保留原始设计weather 后完成是最坏情形
var wg sync.WaitGroup
errs := make(chan error, 2)
wg.Add(2)
go func() { defer wg.Done(); errs <- runSanitizer() }()
go func() { defer wg.Done(); errs <- runWeather() }()
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("插件执行失败: %v", err)
}
}
var final pubsdk.StageContext
if err := seg.ReadInto(&final); err != nil {
t.Fatalf("ReadInto: %v", err)
}
got, _ := final.ToolResults[0].Result.(string)
if got != clean {
t.Fatalf("清洗结果被覆盖:期望 %q实际 %q", clean, got)
}
}
// 多插件高并发累加同一字段:总写入次数必须等于最终长度(零丢失零撕裂)。
// 对应实验 85 进程 × 300 轮),这里在单进程内用 goroutine 模拟并发扇出,
// 验证共享段 + 锁仲裁 + 脏字段写回三者组合的正确性。
func TestSegment_ConcurrentAppend_NoLostUpdate(t *testing.T) {
// arena 需容纳 append-only 的中间垃圾:每轮写入长度递增,
// 5 插件 × 40 轮 → 最长 200 字符,累计约 200*201/2 = 20100 字节,留足余量。
seg := newTestSegment(t, 128*1024)
if err := seg.WriteAll(&pubsdk.StageContext{FinalText: ""}); err != nil {
t.Fatalf("WriteAll: %v", err)
}
lock := newStageLock()
tags := []string{"A", "B", "C", "D", "E"}
const iters = 40
var wg sync.WaitGroup
errCh := make(chan error, len(tags)*iters)
for _, tag := range tags {
wg.Add(1)
go func(tag string) {
defer wg.Done()
for i := 0; i < iters; i++ {
if err := lock.Acquire(tag); err != nil {
errCh <- err
return
}
var local pubsdk.StageContext
if err := seg.ReadInto(&local); err != nil {
lock.Release(tag)
errCh <- err
return
}
snap := TakeSnapshot(&local)
local.FinalText += tag // 读-改-写
if _, err := seg.WriteDirty(&local, snap); err != nil {
lock.Release(tag)
errCh <- err
return
}
if err := lock.Release(tag); err != nil {
errCh <- err
return
}
}
}(tag)
}
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
t.Fatalf("并发写入失败: %v", err)
}
}
var final pubsdk.StageContext
if err := seg.ReadInto(&final); err != nil {
t.Fatalf("ReadInto: %v", err)
}
// 关键断言:各标记出现次数之和 == 最终长度 ⇒ 无丢失、无撕裂
total := 0
counts := map[string]int{}
for _, tag := range tags {
c := strings.Count(final.FinalText, tag)
counts[tag] = c
total += c
}
if total != len(final.FinalText) {
t.Fatalf("出现撕裂:各标记计数之和 %d != 最终长度 %dcounts=%v",
total, len(final.FinalText), counts)
}
want := len(tags) * iters
if total != want {
t.Fatalf("出现 lost update期望 %d 次写入全部保留,实际 %dcounts=%v",
want, total, counts)
}
for tag, c := range counts {
if c != iters {
t.Errorf("插件 %s 的写入丢失:期望 %d 次,实际 %d 次", tag, iters, c)
}
}
}
// arena 用尽必须显式报错不得静默截断§4.4 风险登记)。
func TestSegment_ArenaExhaustionReturnsError(t *testing.T) {
seg := newTestSegment(t, headerSize+ctxSize+256) // 极小 arena
big := strings.Repeat("x", 1024)
err := seg.WriteAll(&pubsdk.StageContext{FinalText: big})
if err == nil {
t.Fatal("arena 不足应报错,而非静默截断")
}
if !strings.Contains(err.Error(), "arena 空间不足") {
t.Errorf("错误信息应说明 arena 不足,实际: %v", err)
}
}
// 压实回收 append-only 垃圾,且不破坏现存字段。
func TestSegment_CompactReclaimsGarbage(t *testing.T) {
seg := newTestSegment(t, 32*1024)
if err := seg.WriteAll(&pubsdk.StageContext{FinalText: "初始"}); err != nil {
t.Fatalf("WriteAll: %v", err)
}
// 反复改写同一字段,制造 append-only 垃圾
for i := 0; i < 50; i++ {
var local pubsdk.StageContext
if err := seg.ReadInto(&local); err != nil {
t.Fatalf("ReadInto: %v", err)
}
snap := TakeSnapshot(&local)
local.FinalText = fmt.Sprintf("第%d次改写内容", i)
if _, err := seg.WriteDirty(&local, snap); err != nil {
t.Fatalf("WriteDirty: %v", err)
}
}
usedBefore := seg.ArenaUsed()
var beforeCtx pubsdk.StageContext
if err := seg.ReadInto(&beforeCtx); err != nil {
t.Fatalf("ReadInto: %v", err)
}
reclaimed := seg.Compact()
if reclaimed == 0 {
t.Error("应回收到垃圾空间")
}
if seg.ArenaUsed() >= usedBefore {
t.Errorf("压实后已用空间应下降:%d → %d", usedBefore, seg.ArenaUsed())
}
var afterCtx pubsdk.StageContext
if err := seg.ReadInto(&afterCtx); err != nil {
t.Fatalf("压实后 ReadInto: %v", err)
}
if afterCtx.FinalText != beforeCtx.FinalText {
t.Errorf("压实破坏了字段内容:%q → %q", beforeCtx.FinalText, afterCtx.FinalText)
}
}

View File

@ -0,0 +1,514 @@
package proc
import (
"encoding/json"
"fmt"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// StageContext 的跨进程编解码§3.3 数据面 / §3.4 SDK 封装全部复杂度)。
//
// 设计要点:
//
// 1. **插件作者永远不接触 Slice{off,len}**。插件进程内保留原生
// *pubsdk.StageContexthandler 照常读写字段stage 入口从共享段
// 反序列化成本地对象handler 返回时把**脏字段**写回共享段。
//
// 2. **字段级描述符消除 lost update**。只改 FinalText 的插件不触碰
// ToolResults 的描述符,故不存在"只读插件把旧快照写回覆盖他人改写"
// (今日副本模型实测 35.8~36.8% 丢失§8.4)。
//
// 3. **全部 16 个字段可见**。今日经 C ABI 只下发 10 个字段,外部插件永远
// 看不到 ContextMsgs/ReasoningContent/TokenUsage/Memory/Extra/Errors§8.3
// 共享内存下全部可见可改——接口形式不变,能力变强。
//
// 4. **Extra 的 4 个键提升为具名字段**§3.3 已核实全部使用点仅这 4 个):
// media_blocks / media_type / input_source / output_channel。
// 它们都是内核写、插件读,无并发改写需求;真正需要多插件并发改的
// LLMText/FinalText/ToolCalls/Errors全是强类型字段。
// extra 中被提升为具名字段的键。
const (
ExtraKeyMediaBlocks = "media_blocks"
ExtraKeyMediaType = "media_type"
ExtraKeyInputSource = "input_source"
ExtraKeyOutputChannel = "output_channel"
)
// WriteAll 把整个 StageContext 写入共享段(内核侧在 stage 开始前调用一次)。
// 调用方须持有写锁。
func (s *Segment) WriteAll(sc *pubsdk.StageContext) error {
sc.RLock()
snap := captureLocal(sc)
sc.RUnlock()
return s.writeLocal(snap)
}
// localCtx 是 StageContext 的值快照,用于在不持有 sc 锁的情况下做编解码。
type localCtx struct {
RawMessage string
UserID string
GroupID string
LLMText string
ReasoningContent string
FinalText string
Response *string
Phase string
NoMemory bool
ContextMsgs []map[string]interface{}
ToolCalls []pubsdk.ToolCall
ToolResults []pubsdk.ToolResult
Memory []pubsdk.MemItem
TokenUsage map[string]int
Errors []string
ExtraMediaBlocks interface{}
ExtraMediaType interface{}
ExtraInputSource interface{}
ExtraOutputChan interface{}
}
func captureLocal(sc *pubsdk.StageContext) *localCtx {
l := &localCtx{
RawMessage: sc.RawMessage,
UserID: sc.UserID,
GroupID: sc.GroupID,
LLMText: sc.LLMText,
ReasoningContent: sc.ReasoningContent,
FinalText: sc.FinalText,
Response: sc.Response,
Phase: string(sc.Phase),
NoMemory: sc.NoMemory,
ContextMsgs: sc.ContextMsgs,
ToolCalls: sc.ToolCalls,
ToolResults: sc.ToolResults,
Memory: sc.Memory,
TokenUsage: sc.TokenUsage,
Errors: sc.Errors,
}
if sc.Extra != nil {
l.ExtraMediaBlocks = sc.Extra[ExtraKeyMediaBlocks]
l.ExtraMediaType = sc.Extra[ExtraKeyMediaType]
l.ExtraInputSource = sc.Extra[ExtraKeyInputSource]
l.ExtraOutputChan = sc.Extra[ExtraKeyOutputChannel]
}
return l
}
func (s *Segment) writeLocal(l *localCtx) error {
putStr := func(f stageField, v string) error {
sl, err := s.write([]byte(v))
if err != nil {
return fmt.Errorf("写入字段 %s: %w", f, err)
}
s.setDesc(f, sl)
return nil
}
putJSON := func(f stageField, v interface{}) error {
if v == nil {
s.setDesc(f, Slice{})
return nil
}
b, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("序列化字段 %s: %w", f, err)
}
sl, err := s.write(b)
if err != nil {
return fmt.Errorf("写入字段 %s: %w", f, err)
}
s.setDesc(f, sl)
return nil
}
for _, step := range []struct {
f stageField
v string
}{
{fRawMessage, l.RawMessage},
{fUserID, l.UserID},
{fGroupID, l.GroupID},
{fLLMText, l.LLMText},
{fReasoningContent, l.ReasoningContent},
{fFinalText, l.FinalText},
{fPhase, l.Phase},
} {
if err := putStr(step.f, step.v); err != nil {
return err
}
}
// Response 是 *string用标志位表达 nil避免 "" 与 nil 混淆
if l.Response != nil {
if err := putStr(fResponse, *l.Response); err != nil {
return err
}
s.setFlag(flagResponseSet, true)
} else {
s.setDesc(fResponse, Slice{})
s.setFlag(flagResponseSet, false)
}
s.setFlag(flagNoMemory, l.NoMemory)
// 切片/映射字段nil 与空切片都写成"未设置",避免插件收到 [] 后误以为
// 内核显式清空过(与今日 writable 的 len>0 才下发语义一致)。
jsonFields := []struct {
f stageField
v interface{}
}{
{fContextMsgs, sliceOrNil(len(l.ContextMsgs), l.ContextMsgs)},
{fToolCalls, sliceOrNil(len(l.ToolCalls), l.ToolCalls)},
{fToolResults, sliceOrNil(len(l.ToolResults), l.ToolResults)},
{fMemory, sliceOrNil(len(l.Memory), l.Memory)},
{fTokenUsage, sliceOrNil(len(l.TokenUsage), l.TokenUsage)},
{fErrors, sliceOrNil(len(l.Errors), l.Errors)},
{fExtraMediaBlocks, l.ExtraMediaBlocks},
{fExtraMediaType, l.ExtraMediaType},
{fExtraInputSource, l.ExtraInputSource},
{fExtraOutputChannel, l.ExtraOutputChan},
}
for _, step := range jsonFields {
if err := putJSON(step.f, step.v); err != nil {
return err
}
}
s.bumpSeq()
return nil
}
// sliceOrNil 让长度为 0 的容器写成 nil未设置非零则原样返回。
func sliceOrNil(n int, v interface{}) interface{} {
if n == 0 {
return nil
}
return v
}
// ReadInto 从共享段读出全部字段填充到 sc插件进程侧 stage 入口调用)。
// 调用方须持有读锁或写锁。
func (s *Segment) ReadInto(sc *pubsdk.StageContext) error {
getStr := func(f stageField) (string, error) {
b, err := s.read(s.getDesc(f))
if err != nil {
return "", fmt.Errorf("读取字段 %s: %w", f, err)
}
return string(b), nil
}
getJSON := func(f stageField, out interface{}) error {
b, err := s.read(s.getDesc(f))
if err != nil {
return fmt.Errorf("读取字段 %s: %w", f, err)
}
if len(b) == 0 {
return nil
}
if err := json.Unmarshal(b, out); err != nil {
return fmt.Errorf("反序列化字段 %s: %w", f, err)
}
return nil
}
raw, err := getStr(fRawMessage)
if err != nil {
return err
}
uid, err := getStr(fUserID)
if err != nil {
return err
}
gid, err := getStr(fGroupID)
if err != nil {
return err
}
llm, err := getStr(fLLMText)
if err != nil {
return err
}
reason, err := getStr(fReasoningContent)
if err != nil {
return err
}
final, err := getStr(fFinalText)
if err != nil {
return err
}
phase, err := getStr(fPhase)
if err != nil {
return err
}
var ctxMsgs []map[string]interface{}
var toolCalls []pubsdk.ToolCall
var toolResults []pubsdk.ToolResult
var mem []pubsdk.MemItem
var usage map[string]int
var errs []string
if err := getJSON(fContextMsgs, &ctxMsgs); err != nil {
return err
}
if err := getJSON(fToolCalls, &toolCalls); err != nil {
return err
}
if err := getJSON(fToolResults, &toolResults); err != nil {
return err
}
if err := getJSON(fMemory, &mem); err != nil {
return err
}
if err := getJSON(fTokenUsage, &usage); err != nil {
return err
}
if err := getJSON(fErrors, &errs); err != nil {
return err
}
extra := map[string]interface{}{}
for _, pair := range []struct {
f stageField
key string
}{
{fExtraMediaBlocks, ExtraKeyMediaBlocks},
{fExtraMediaType, ExtraKeyMediaType},
{fExtraInputSource, ExtraKeyInputSource},
{fExtraOutputChannel, ExtraKeyOutputChannel},
} {
var v interface{}
if err := getJSON(pair.f, &v); err != nil {
return err
}
if v != nil {
extra[pair.key] = v
}
}
var respPtr *string
if s.getFlag(flagResponseSet) {
r, err := getStr(fResponse)
if err != nil {
return err
}
respPtr = &r
}
sc.Lock()
defer sc.Unlock()
sc.RawMessage = raw
sc.UserID = uid
sc.GroupID = gid
sc.LLMText = llm
sc.ReasoningContent = reason
sc.FinalText = final
sc.Phase = pubsdk.Stage(phase)
sc.NoMemory = s.getFlag(flagNoMemory)
sc.Response = respPtr
sc.ContextMsgs = ctxMsgs
sc.ToolCalls = toolCalls
sc.ToolResults = toolResults
sc.Memory = mem
sc.TokenUsage = usage
sc.Errors = errs
if len(extra) > 0 {
sc.Extra = extra
} else {
sc.Extra = nil
}
return nil
}
// WriteDirty 只把与 base 快照不同的字段写回共享段(插件进程侧 handler 返回后调用)。
//
// **这是消除 lost update 的关键**:只读插件的 dirty 集为空 → 零写入 →
// 不可能覆盖其他插件的改写。对比今日副本模型无条件回传 10 个字段的行为
// §8.4 实测 35.8~36.8% 丢失,现网量级百分之几的脏数据进 LLM
//
// 返回实际写回的字段数,便于诊断与测试断言。
func (s *Segment) WriteDirty(sc *pubsdk.StageContext, base *Snapshot) (int, error) {
sc.RLock()
cur := captureLocal(sc)
sc.RUnlock()
now := newSnapshotFromLocal(cur)
changed := 0
putStr := func(f stageField, v string) error {
sl, err := s.write([]byte(v))
if err != nil {
return fmt.Errorf("写回字段 %s: %w", f, err)
}
s.setDesc(f, sl)
changed++
return nil
}
putRaw := func(f stageField, raw string) error {
if raw == "" {
s.setDesc(f, Slice{})
changed++
return nil
}
sl, err := s.write([]byte(raw))
if err != nil {
return fmt.Errorf("写回字段 %s: %w", f, err)
}
s.setDesc(f, sl)
changed++
return nil
}
for _, step := range []struct {
f stageField
v string
}{
{fRawMessage, cur.RawMessage},
{fUserID, cur.UserID},
{fGroupID, cur.GroupID},
{fLLMText, cur.LLMText},
{fReasoningContent, cur.ReasoningContent},
{fFinalText, cur.FinalText},
{fPhase, cur.Phase},
} {
if base.strs[step.f] != now.strs[step.f] {
if err := putStr(step.f, step.v); err != nil {
return changed, err
}
}
}
// JSON 字段:比较序列化结果
for f := range now.jsons {
if base.jsons[f] != now.jsons[f] {
if err := putRaw(f, now.jsons[f]); err != nil {
return changed, err
}
}
}
// Response 的 nil 语义变化也算脏
if base.responseSet != now.responseSet || base.response != now.response {
if now.responseSet {
if err := putStr(fResponse, now.response); err != nil {
return changed, err
}
s.setFlag(flagResponseSet, true)
} else {
// 插件把 Response 置回 nil短路语义不应被撑销故不清空内核已设的值。
// 与 C ABI 路径 applyStageResult 的行为保持一致。
s.setFlag(flagResponseSet, s.getFlag(flagResponseSet))
}
}
if base.noMemory != now.noMemory {
s.setFlag(flagNoMemory, now.noMemory)
changed++
}
if changed > 0 {
s.bumpSeq()
}
return changed, nil
}
// Snapshot 是 handler 运行前的字段快照,用于计算脏字段。
//
// ❗ 必须存**序列化后的字符串**而非 Go 值StageContext 的切片字段与
// 调用方共享底层数组handler 原地改元素sc.ToolResults[0].Result = x
// 时直接持有的 Go 值快照会跟着变,脏字段计算失效——这个坑在 C ABI 侧
// 修 11.3 时已经踩过一次(见 SDK 仓 stagediff_test.go 的注释)。
type Snapshot struct {
strs map[stageField]string
jsons map[stageField]string
response string
responseSet bool
noMemory bool
}
// Snapshot 抓取当前 StageContext 的快照(插件进程侧 handler 前调用)。
func TakeSnapshot(sc *pubsdk.StageContext) *Snapshot {
sc.RLock()
l := captureLocal(sc)
sc.RUnlock()
return newSnapshotFromLocal(l)
}
func newSnapshotFromLocal(l *localCtx) *Snapshot {
sn := &Snapshot{
strs: map[stageField]string{},
jsons: map[stageField]string{},
}
sn.strs[fRawMessage] = l.RawMessage
sn.strs[fUserID] = l.UserID
sn.strs[fGroupID] = l.GroupID
sn.strs[fLLMText] = l.LLMText
sn.strs[fReasoningContent] = l.ReasoningContent
sn.strs[fFinalText] = l.FinalText
sn.strs[fPhase] = l.Phase
marshal := func(v interface{}) string {
if v == nil {
return ""
}
b, err := json.Marshal(v)
if err != nil {
return ""
}
return string(b)
}
sn.jsons[fContextMsgs] = marshal(sliceOrNil(len(l.ContextMsgs), l.ContextMsgs))
sn.jsons[fToolCalls] = marshal(sliceOrNil(len(l.ToolCalls), l.ToolCalls))
sn.jsons[fToolResults] = marshal(sliceOrNil(len(l.ToolResults), l.ToolResults))
sn.jsons[fMemory] = marshal(sliceOrNil(len(l.Memory), l.Memory))
sn.jsons[fTokenUsage] = marshal(sliceOrNil(len(l.TokenUsage), l.TokenUsage))
sn.jsons[fErrors] = marshal(sliceOrNil(len(l.Errors), l.Errors))
sn.jsons[fExtraMediaBlocks] = marshal(l.ExtraMediaBlocks)
sn.jsons[fExtraMediaType] = marshal(l.ExtraMediaType)
sn.jsons[fExtraInputSource] = marshal(l.ExtraInputSource)
sn.jsons[fExtraOutputChannel] = marshal(l.ExtraOutputChan)
if l.Response != nil {
sn.response = *l.Response
sn.responseSet = true
}
sn.noMemory = l.NoMemory
return sn
}
// String 让字段枚举在错误信息里可读。
func (f stageField) String() string {
switch f {
case fRawMessage:
return "raw_message"
case fUserID:
return "user_id"
case fGroupID:
return "group_id"
case fLLMText:
return "llm_text"
case fReasoningContent:
return "reasoning_content"
case fFinalText:
return "final_text"
case fResponse:
return "response"
case fPhase:
return "phase"
case fContextMsgs:
return "context_msgs"
case fToolCalls:
return "tool_calls"
case fToolResults:
return "tool_results"
case fMemory:
return "memory"
case fTokenUsage:
return "token_usage"
case fErrors:
return "errors"
case fExtraMediaBlocks:
return "extra." + ExtraKeyMediaBlocks
case fExtraMediaType:
return "extra." + ExtraKeyMediaType
case fExtraInputSource:
return "extra." + ExtraKeyInputSource
case fExtraOutputChannel:
return "extra." + ExtraKeyOutputChannel
}
return fmt.Sprintf("field(%d)", int(f))
}

View File

@ -0,0 +1,15 @@
package proc
import "unsafe"
// ptrU32 / ptrU64 把段内字节切片起始处重解释为原子操作可用的指针。
//
// 共享段由 mmap 得到其起始地址天然按页对齐4096本包所有原子字段
// offArenaUsed=16 对齐 4、offSeq=24 对齐 8都落在对齐位置
// 因此该重解释是安全的。
//
// 这是本包唯一使用 unsafe 的地方,且**不涉及 cgo**——
// 迁移的一个目标就是整个新架构零 cgo§3.7 锁仲裁回归内核)。
func ptrU32(b []byte) unsafe.Pointer { return unsafe.Pointer(&b[0]) }
func ptrU64(b []byte) unsafe.Pointer { return unsafe.Pointer(&b[0]) }

View File

@ -360,10 +360,12 @@ func (r *Registry) isDisabled(name string) bool {
return r.cfgReg.IsPluginDisabled(name) return r.cfgReg.IsPluginDisabled(name)
} }
// pluginEntryHash 计算插件入口文件plugin.so 或 main.lua的 SHA256用于增量重载对比。 // pluginEntryHash 计算插件入口文件的 SHA256用于增量重载对比。
// 无入口文件(内置纯工厂插件)返回空字符串(始终视为已加载)。 // 无入口文件(内置纯工厂插件)返回空字符串(始终视为已加载)。
// plugin.bin 排在最前:与 detectEntryKind 保持一致的优先级,迁移期间同目录
// 两种产物共存时以子进程产物为准。
func pluginEntryHash(plgDir string) string { func pluginEntryHash(plgDir string) string {
for _, candidate := range []string{"plugin.so", "plugin.dll", "main.lua", "SKILL.md"} { for _, candidate := range []string{binEntry, soEntry, dllEntry, "plugin.dylib", luaEntry, skillEntry} {
path := filepath.Join(plgDir, candidate) path := filepath.Join(plgDir, candidate)
if data, err := os.ReadFile(path); err == nil && len(data) > 0 { if data, err := os.ReadFile(path); err == nil && len(data) > 0 {
sum := sha256.Sum256(data) sum := sha256.Sum256(data)
@ -763,6 +765,7 @@ func (r *Registry) DisablePlugin(name, by string) error {
} }
func (r *Registry) EnablePlugin(name string) error { return r.Enable(name) } func (r *Registry) EnablePlugin(name string) error { return r.Enable(name) }
// StopAndUnload 停止并从注册表移除插件但保留其配置表config_<name>)。 // StopAndUnload 停止并从注册表移除插件但保留其配置表config_<name>)。
// 供插件更新/升级流程使用:换 so/文件不动配置,重装后配置原样生效。 // 供插件更新/升级流程使用:换 so/文件不动配置,重装后配置原样生效。
// 不执行 onRemove 回调(那是删除专用语义)。目录由调用方管理。 // 不执行 onRemove 回调(那是删除专用语义)。目录由调用方管理。
@ -888,7 +891,23 @@ func (r *Registry) PluginDir() string {
} }
func (r *Registry) tryDynamic(plgDir, name string, config map[string]interface{}) (sdk.Plugin, error) { func (r *Registry) tryDynamic(plgDir, name string, config map[string]interface{}) (sdk.Plugin, error) {
// 尝试顺序:.so (Go plugin on Linux) → .dll (Windows) → .lua (跨平台) // 按 manifest entry 分派到对应加载通道(外部插件多进程化:.so/.dll 与 .bin 双通道共存)。
// 这使迁移可逐插件推进、随时回退——把 entry 改回 plugin.so 即回到旧通道。
if detectEntryKind(plgDir) == entryProc {
plg, err := tryLoadProc(plgDir, name, config)
if err != nil {
return nil, err
}
if plg != nil {
log.Printf("[plugin] %s: 经 proc 通道加载(子进程)", name)
return plg, nil
}
// entry 声明了 plugin.bin 但文件不存在/不可用 → 不隐式回退到 cabi
// 否则"已迁移插件静默跑回旧通道"极难排查。
return nil, fmt.Errorf("plugin %s: entry 声明 %s 但未找到可用二进制", name, binEntry)
}
// 既有探测顺序(保持不变):.so → .dll → .lua
for _, try := range []struct { for _, try := range []struct {
name string name string
fn func(string, string, map[string]interface{}) (sdk.Plugin, error) fn func(string, string, map[string]interface{}) (sdk.Plugin, error)