mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
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:
@ -7,12 +7,84 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
soEntry = "plugin.so"
|
||||
dllEntry = "plugin.dll"
|
||||
luaEntry = "main.lua"
|
||||
metaEntry = "plugin.json"
|
||||
soEntry = "plugin.so"
|
||||
dllEntry = "plugin.dll"
|
||||
binEntry = "plugin.bin" // 子进程插件(纯 Go 二进制,stdio JSON-RPC)
|
||||
luaEntry = "main.lua"
|
||||
skillEntry = "SKILL.md"
|
||||
metaEntry = "plugin.json"
|
||||
)
|
||||
|
||||
// entryKind 描述插件入口归属的加载通道。
|
||||
// 外部插件多进程化期间 .so/.dll(cabi)与 .bin(proc)**双通道共存**,
|
||||
// 按 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 {
|
||||
data, err := os.ReadFile(filepath.Join(dir, metaEntry))
|
||||
if err != nil {
|
||||
|
||||
43
internal/plugin/dynamic_proc_unix.go
Normal file
43
internal/plugin/dynamic_proc_unix.go
Normal 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 §3(stdio 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)
|
||||
}
|
||||
25
internal/plugin/dynamic_proc_windows.go
Normal file
25
internal/plugin/dynamic_proc_windows.go
Normal 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 mmap,Windows 需改用 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
|
||||
}
|
||||
160
internal/plugin/entry_dispatch_test.go
Normal file
160
internal/plugin/entry_dispatch_test.go
Normal file
@ -0,0 +1,160 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// entry 分派骨架(docs/zh/plugin-migration-plan.md Part 1):
|
||||
// 外部插件多进程化期间 .so/.dll(cabi)与 .bin(proc)双通道共存,
|
||||
// 按 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)
|
||||
}
|
||||
}
|
||||
@ -19,7 +19,7 @@ type PluginManifest struct {
|
||||
License string `json:"license,omitempty"`
|
||||
Homepage string `json:"homepage,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"]
|
||||
MinVersion string `json:"min_version,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
|
||||
131
internal/plugin/proc/lock.go
Normal file
131
internal/plugin/proc/lock.go
Normal 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(实验 3,20000 次测得)。stage handler 的加锁
|
||||
// 频率很低(每次 stage 一两次,不是每字段一次),微秒级往返可忽略。
|
||||
//
|
||||
// 于是整个新架构可做到**完全无 cgo**。
|
||||
|
||||
// lockWaitTimeout 是插件申请 stage 锁的最长等待时间。
|
||||
//
|
||||
// 取 30s:stage 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
|
||||
}
|
||||
184
internal/plugin/proc/lock_test.go
Normal file
184
internal/plugin/proc/lock_test.go
Normal 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
307
internal/plugin/proc/shm.go
Normal 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) arena:append-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
|
||||
}
|
||||
441
internal/plugin/proc/shm_test.go
Normal file
441
internal/plugin/proc/shm_test.go
Normal 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 + RWMutex):0% 丢失
|
||||
// - 外部插件(快照-副本-写回):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)
|
||||
}
|
||||
}
|
||||
|
||||
// 复刻现网场景(实验 13):sanitizer 改写 + 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 多插件高并发累加同一字段:总写入次数必须等于最终长度(零丢失零撕裂)。
|
||||
// 对应实验 8(5 进程 × 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 != 最终长度 %d(counts=%v)",
|
||||
total, len(final.FinalText), counts)
|
||||
}
|
||||
want := len(tags) * iters
|
||||
if total != want {
|
||||
t.Fatalf("出现 lost update:期望 %d 次写入全部保留,实际 %d(counts=%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)
|
||||
}
|
||||
}
|
||||
514
internal/plugin/proc/shmcodec.go
Normal file
514
internal/plugin/proc/shmcodec.go
Normal 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.StageContext,handler 照常读写字段;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))
|
||||
}
|
||||
15
internal/plugin/proc/unsafe.go
Normal file
15
internal/plugin/proc/unsafe.go
Normal 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]) }
|
||||
@ -360,10 +360,12 @@ func (r *Registry) isDisabled(name string) bool {
|
||||
return r.cfgReg.IsPluginDisabled(name)
|
||||
}
|
||||
|
||||
// pluginEntryHash 计算插件入口文件(plugin.so 或 main.lua)的 SHA256,用于增量重载对比。
|
||||
// pluginEntryHash 计算插件入口文件的 SHA256,用于增量重载对比。
|
||||
// 无入口文件(内置纯工厂插件)返回空字符串(始终视为已加载)。
|
||||
// plugin.bin 排在最前:与 detectEntryKind 保持一致的优先级,迁移期间同目录
|
||||
// 两种产物共存时以子进程产物为准。
|
||||
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)
|
||||
if data, err := os.ReadFile(path); err == nil && len(data) > 0 {
|
||||
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) }
|
||||
|
||||
// StopAndUnload 停止并从注册表移除插件,但保留其配置表(config_<name>)。
|
||||
// 供插件更新/升级流程使用:换 so/文件不动配置,重装后配置原样生效。
|
||||
// 不执行 onRemove 回调(那是删除专用语义)。目录由调用方管理。
|
||||
@ -888,7 +891,23 @@ func (r *Registry) PluginDir() string {
|
||||
}
|
||||
|
||||
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 {
|
||||
name string
|
||||
fn func(string, string, map[string]interface{}) (sdk.Plugin, error)
|
||||
|
||||
Reference in New Issue
Block a user