Files
HomeAgent/internal/plugin/proc/shm_test.go
JianFeeeee 6d38a164eb chore(module): Go module 路径随托管地迁移 gitcode → github
## 背景

仓库早已迁移到 GitHub(`github.com/JianFeeeee/HomeAgent`,CI/CD 都在那边跑),
gitcode 保留为**国内只读镜像**(`ci.yml` 的 sync-gitcode-main 快进推送)。
但 go.mod 的 module 路径一直是 `gitcode.com/JianFeeeee/...` —— 既与权威源
不符,`meta.SourceURL`(AGPL §13 的源码提供入口)也指向镜像而非实际运行的代码。

## 关键区分(这是本次改动安全的前提)

两个 module 是**不同**的,影响面差一个数量级:

| module | 谁依赖 | 改它的后果 |
|---|---|---|
| `.../HomeAgent`(主仓) | **只有主仓自己** | 内部重构,零外部影响 |
| `.../homeagent-sdk`(SDK 仓) | 主仓 49 文件 + 所有外部插件 | 需重编插件 |

★ 实测确认:**插件不 import 主仓 module**(`strings qq/plugin.bin | grep HomeAgent` = 0),
只依赖 SDK 仓。而插件与内核的边界是 **RPC 方法名字符串**
(`plugin.init` / `tool.invoke`…,见 proc/protocol.go),**与 Go module 名无关**。
⇒ module 改名**不改变任何 wire 协议**,现网已装的插件照常运行。

SDK 的 GitHub 仓名是 `homeagentsdk`(无连字符),module 路径必须与之精确匹配,
否则 `go get` 会因路径不符失败,故取 `github.com/JianFeeeee/homeagentsdk`。

## 改动

- `go.mod` / `go.sum`:两个 module 路径 + require/replace
- 277 个 Go 文件 + Makefile + 打包脚本的 import 与 ldflags 路径
- SDK 仓 `meta.CoreModule`、`tools/hmapdev` 的 module 声明与工程模板
- 对外文档/链接的仓库地址(README、PLUGIN_DEV、OVERVIEW 等)

## 明确**保留** gitcode 的地方(镜像机制,不能动)

- `GITCODE_TOKEN`、`GC_URL="…@gitcode.com"`(CI 同步 job)
- `gitcode.com/api/v5/...`(release 附件上传)
- git remote `gitcode`(镜像拉取/推送)
- `docs/zh/ci-cd-runbook.md`(整篇讲镜像运维)

## 验证

- `go build ./...` / `go vet ./...` 绿
- `go test ./... -count=1` → 48 包 ok / 0 FAIL
- hmapdev 工具链编译通过(`third_party/homeagent-sdk/tools/hmapdev`)
- 全仓残留检查:module 形态的 `gitcode.com/JianFeeeee` = 0
- 镜像机制抽验:GC_URL / @gitcode / api/v5 / remote 均完好

## 顺带修正

`hmapdev/templates.go` 的注释此前写「gitcode 的模块不在 proxy.golang.org 上」——
module 已迁 GitHub,该论断不再成立。改为如实说明**为何仍默认 replace 到本机 SDK**:
离线可构建、避免版本漂移、内网可用。理由与托管地无关,故 replace 保留。
2026-10-08 10:40:17 +08:00

442 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package proc
import (
"fmt"
"strings"
"sync"
"testing"
pubsdk "github.com/JianFeeeee/homeagentsdk/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)
}
}