Merge branch 'feature/plugin-proc-migration' into main

插件架构子进程化迁移(8-9 周)+ 崩溃自愈收尾。

## 消除的 6 类 C ABI 前提缺陷(对照 plan.md §11.0)

| 缺陷 | 原状 | 现状 |
|---|---|---|
| 热重载失效 | DF_1_NODELETE 让 dlclose 成 no-op | 换 plugin.bin 即生效 |
| 崩溃隔离缺失 | 插件 panic 带崩 homed | 子进程独立崩溃 + 自动重启 |
| stage lost update | 副本模型互相覆盖 35.8~36.8% | 共享内存段,0% |
| cgo 超时泄漏 | 现网泄漏 26 次线程 | 整套架构零 cgo,Kill 真取消 |
| output_send 假成功 | 永远返回 queued+nil | RPC 同步等真实结果 |
| 能力断层 | Windows 只见 3 字段无写回 | 18 字段全可见可写回 |

## 架构

- 控制面:stdio JSON-RPC(51 个 core.* method)
- 数据面:共享内存段(全部子进程共用一块,避免退化成副本模型)
- 通知面:事件环 + 三平台通知(Linux eventfd / macOS pipe / Windows Event)
- 权限梯度显式化为三道闸:procCore 命名字段 + manifest 能力声明 + RPC 边界拒绝
- C ABI 通道整体删除(-3198 行)

## 子进程生命周期管理

- 每子进程专职 waitLoop(cmd.Wait 唯一调用点),不依赖 stdout EOF
- proc.Supervisor 集中台账,Host.Close 先 StopAll 再拆段
- 崩溃自愈:摘注册面(工具+stage+IO通道)→ 移除注册表 → 退避重启
- Linux Pdeathsig 兜底 homed 被强杀场景

## 门禁

make test 零失败 / go vet 无告警 / SDK 公开接口 diff 为空(接口冻结不变量)
This commit is contained in:
JianFeeeee
2026-09-03 13:46:51 +08:00
119 changed files with 16025 additions and 3286 deletions

17
.gitignore vendored
View File

@ -10,7 +10,6 @@ data/
.tmp-plugins/
.go/
.local/
internal/meta/
*.hmap
dev/
@ -24,6 +23,11 @@ cmd/gui/dist/
.gopath/
# SDK 工具链 — 核心仓不追踪
#
# 外部插件与工具链维护在独立 SDK 仓(决策 sdk_repo_only
# 本仓经 go.mod 的 replace => ./third_party/homeagent-sdk 引用。
# example/ 下已跟踪的 20 个文件plg.json + plugin.go早于本规则
# 靠「已跟踪文件不受 .gitignore 影响」保留——这是有意的,不要「修」。
third_party/homeagent-sdk/bin/
third_party/homeagent-sdk/tools/
third_party/homeagent-sdk/package/
@ -36,10 +40,15 @@ third_party/homeagent-sdk/example/
codegraph.json
# 运行时产物(不提交)
#
# ⚠️ 这些必须带前导斜杠。不带斜杠的模式(如 `memory/`)会匹配**任意层级**的
# 同名目录,把 internal/memory/、internal/knowledge/、deploy/scripts/ 一起吞掉
# ——24 + 3 + 1 个已跟踪源码文件曾因此落在 ignore 规则下,只靠「已跟踪
# 文件不受 .gitignore 影响」这条 git 规则兜着,新增文件会默默不入库。
/adapters/
knowledge/
memory/
scripts/
/knowledge/
/memory/
/scripts/
terminal_locked_log.txt
dist/

View File

@ -29,7 +29,7 @@ type Plugin interface {
| Method | Use Case | Complexity |
|--------|----------|------------|
| **Dynamic .so/.dll plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `plugindev` toolchain |
| **Subprocess plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `plugindev` toolchain |
| **Built-in plugin** | Released with HomeAgent | Simple, requires merging into main repo |
| **Lua script plugin** | Lightweight rapid prototyping | Simple, generated using `plugindev init --lua` |
@ -114,7 +114,7 @@ myplugin/
└── thirdpart/ — Optional external source code directory
```
C ABI bridge files (`z_bridge_gen.go` + `z_entry.c`) are auto-generated at build time.
Subprocess runtime files (`z_proc_gen.go` and friends) are auto-generated at build time.
**Lua plugin**:
@ -142,8 +142,8 @@ plugindev build --replace <mod@path> # append a go.mod replace directive (repeat
Execution process:
1. Reads `plg.json` `targets`/`bundle` fields to determine build targets (bundle takes priority, see below)
2. Auto-generates C ABI bridge code (`z_bridge_gen.go` + `z_entry.c`; Windows only `z_bridge_gen.go`)
3. **Go plugin**: Runs `go build -buildmode=c-shared` (produces `.so` / `.dylib` / `.dll`)
2. Auto-generates subprocess runtime code (`z_proc_gen.go` + `z_proc_shm_unix.go` + `z_proc_shm_windows.go`)
3. **Go plugin**: Runs `go build` (a plain executable, `CGO_ENABLED=0`)
4. **Lua plugin**: Packages source code directly, no compilation needed (contents: `plugin.json` + `main.lua`, plus optional `README.md`, `LICENSE`, `thirdpart/*.lua`)
5. Generates `plugin.json` output manifest
6. Packages as `.hmap` distribution (zip format, containing `plugin.json` + binary)
@ -155,13 +155,28 @@ Execution process:
| `plg.json` | Project metadata, maintained by developer | `targets` — single-target build list (e.g. `"linux/amd64,windows/amd64"`); `bundle` — multi-platform bundle switch (default `true`) |
| `plugin.json` | Build artifact manifest, auto-generated | `entry` — entry filename; `platforms` — declared platforms |
Each target produces a separate `.hmap`; binary name by platform:
Each target produces a separate `.hmap`. Subprocess plugins are plain executables with
**no platform-specific extension**:
| Platform | Binary |
|----------|--------|
| Linux | `plugin.so` |
| macOS | `plugin.dylib` |
| Windows | `plugin.dll` |
| Linux / macOS / Windows | `plugin.bin` |
Inside a bundle package the per-platform entries are named `plugin.bin.<goos>.<goarch>`;
the kernel picks the one matching the current platform and renames it to `plugin.bin`.
> ⚠️ **v1.0.0 breaking change**: external plugins moved from C ABI shared libraries to
> **subprocess + shared memory**.
>
> - `plugin.so` / `plugin.dylib` / `plugin.dll` are **no longer loaded**. The new kernel
> skips legacy artifacts with an actionable error instead of crashing.
> - **Business code needs no changes** — the public SDK interface is unchanged; just
> rebuild with the new `plugindev`.
> - The `entry` field in `plg.json` is **meaningless for Go plugins** now (leaving
> `plugin.so` there is harmless); it only distinguishes Lua plugins.
> - Artifacts no longer need cgo, so cross-compiling requires no target C toolchain.
> - Windows went from "only 3 stage fields delivered, no writeback" to all 16 fields
> visible plus writeback, sharing the same RPC implementation as Unix.
### Build Targets & Multi-platform Bundle
@ -269,8 +284,11 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
}
```
At build time, `plugindev build` auto-generates C ABI bridge code (`z_bridge_gen.go` + `z_entry.c`),
shared by both Windows DLL and Linux/macOS .so builds. No manual bridge code needed.
At build time, `plugindev build` auto-generates subprocess runtime code
(`z_proc_gen.go` for the platform-independent part, plus `z_proc_shm_unix.go` /
`z_proc_shm_windows.go`). All three platforms share the same entry point and the same
RPC logic; only the cross-process resource-passing mechanism differs (inherited fds on
Unix, named kernel objects on Windows). No manual bridge code needed.
### PluginSDK Core API
@ -322,7 +340,7 @@ Tool output → valuable for LLM attention?
└── No → Normal memory, no extra handling
```
> **Note**: `Cleaner` is a Go `func` type (`json:"-"`), cannot cross C ABI boundaries, so it is unavailable for C/C++/Rust remote plugins. **Lua plugins are not affected**: pass a Lua function in the def table (`cleaner = function(text) return text end`) — the Go bridge calls it back per invocation during memory computation.
> **Note**: `Cleaner` is a Go `func` type (`json:"-"`), cannot be serialized across process boundaries, so it is unavailable for C/C++/Rust remote plugins. **Lua plugins are not affected**: pass a Lua function in the def table (`cleaner = function(text) return text end`) — the Go bridge calls it back per invocation during memory computation.
#### Stage Hooks — Intervene in message processing flow
@ -527,7 +545,7 @@ Lua plugins run inside the kernel process on a gopher-lua interpreter (single Lu
- **Passive callback model**: `main.lua` executes only once at load time. Afterward, tools, stage hooks, output/input channels, and registered APIs are all invoked by the kernel via callbacks into Lua functions. Plugins cannot start background tasks on their own.
- **No concurrency / no long-running services**: Lua has no goroutines, coroutine scheduling, `os`/`io` libraries, or socket listening. The only outbound capability is `sdk.http.get/post` (synchronous). Any blocking loop will stall every call of that plugin while holding the lock.
- **For long-running services (listening on a port, background polling, timers) use a Go plugin** (`.so`/`.dll` built with the toolchain, which may spawn goroutines — see the webui/cli plugins). The Lua equivalent is event-driven: register tools/stage hooks/channels to be called back by the kernel, or interact with external processes via `sdk.http`.
- **For long-running services (listening on a port, background polling, timers) use a Go plugin** (`plugin.bin` built with the toolchain, which may spawn goroutines — see the webui/cli plugins). The Lua equivalent is event-driven: register tools/stage hooks/channels to be called back by the kernel, or interact with external processes via `sdk.http`.
### Plugin Structure
@ -576,7 +594,7 @@ When running inside the kernel, `sdk.*` global variables are injected by the Go
### Lua SDK API
The `sdk.*` API of Lua plugins is fully aligned with external plugins (C ABI / toolchain-built `.so`/`.dll`): registration functions raise a Lua error on failure; data functions uniformly return `(result, err)` with `err == nil` on success. Subsystems not wired by the core (e.g. SocialAPI) return empty values instead of errors.
The `sdk.*` API of Lua plugins is fully aligned with external plugins (toolchain-built `plugin.bin` subprocesses): registration functions raise a Lua error on failure; data functions uniformly return `(result, err)` with `err == nil` on success. Subsystems not wired by the core (e.g. SocialAPI) return empty values instead of errors.
**Registration**
@ -594,7 +612,7 @@ The `sdk.*` API of Lua plugins is fully aligned with external plugins (C ABI / t
Stage handlers receive the full context (same as external plugins): `raw_message`, `user_id`, `group_id`, `phase`, `llm_text`, `final_text`, `no_memory`, `response` (when responded), `tool_calls`, `tool_results`.
**Stage writeback (ABI v2)**: the `ctx` table passed to the handler is a reference — mutating writable fields inside the handler syncs back to the core `StageContext` (aligned with the C ABI v2 external-plugin capability):
**Stage writeback**: the `ctx` table passed to the handler is a reference — mutating writable fields inside the handler syncs back to the core `StageContext` (aligned with subprocess external-plugin capability):
```lua
sdk.register_stage("on_input", function(ctx)
@ -622,7 +640,7 @@ Writable fields: `raw_message`, `llm_text`, `final_text`, `user_id`, `group_id`,
| `sdk.inject_interrupt(source, channel, text)` | Interrupt delivery |
| `sdk.inject_text_no_memory(source, channel, text)` | Deliver without memory computation |
**Data APIs (aligned with C ABI, all return `(result, err)`)**
**Data APIs (aligned with subprocess external plugins, all return `(result, err)`)**
| Sub-table | Functions |
|-----------|-----------|

View File

@ -30,7 +30,7 @@ type Plugin interface {
| 方式 | 适用场景 | 复杂度 |
|------|---------|--------|
| **动态 .so/.dll 插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `plugindev` 工具链生成 |
| **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `plugindev` 工具链生成 |
| **内置插件** | 随 HomeAgent 一起发布 | 简单,需合入主仓库 |
| **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `plugindev init --lua` 生成 |
@ -115,7 +115,7 @@ myplugin/
└── thirdpart/ — 外部源码存放目录(可选)
```
编译时自动生成 C ABI bridge 文件(`z_bridge_gen.go` + `z_entry.c`),无需手动创建。
编译时自动生成子进程运行时文件(`z_proc_gen.go` ),无需手动创建。
**Lua 插件**
@ -143,8 +143,8 @@ plugindev build --replace <mod@path> # 追加 go.mod replace 指令(可多次
执行过程:
1. 读取 `plg.json``targets`/`bundle` 字段确定构建目标bundle 模式优先,见下节)
2. 自动生成 C ABI bridge 代码(`z_bridge_gen.go` + `z_entry.c`Windows 仅 `z_bridge_gen.go`
3. **Go 插件**:执行 `go build -buildmode=c-shared`(生成 `.so` / `.dylib` / `.dll`
2. 自动生成子进程运行时代码(`z_proc_gen.go` + `z_proc_shm_unix.go` + `z_proc_shm_windows.go`
3. **Go 插件**:执行 `go build`(普通可执行文件,`CGO_ENABLED=0`
4. **Lua 插件**:直接打包源码,无需编译(打包内容:`plugin.json` + `main.lua`,以及可选的 `README.md``LICENSE``thirdpart/*.lua`
5. 生成 `plugin.json` 输出清单
6. 打包为 `.hmap` 分发包zip 格式,内含 `plugin.json` + 二进制)
@ -156,13 +156,25 @@ plugindev build --replace <mod@path> # 追加 go.mod replace 指令(可多次
| `plg.json` | 项目元信息,由开发者维护 | `targets` — 单平台构建目标(如 `"linux/amd64,windows/amd64"``bundle` — 多平台合集开关(默认 `true`|
| `plugin.json` | 构建产物清单,`plugindev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 |
每个目标生成单独的 `.hmap`,二进制文件名由平台决定
每个目标生成单独的 `.hmap`。子进程插件是普通可执行文件,**不分平台后缀**
| 平台 | 二进制 |
|------|--------|
| Linux | `plugin.so` |
| macOS | `plugin.dylib` |
| Windows | `plugin.dll` |
| Linux / macOS / Windows | `plugin.bin` |
bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分各平台,安装时内核挑当前平台
那份重命名为 `plugin.bin`
> ⚠️ **v1.0.0 破坏性变更**:外部插件从 C ABI 动态库改为**子进程 + 共享内存**。
>
> - `plugin.so` / `plugin.dylib` / `plugin.dll` **不再被加载**。新内核遇到旧产物
> 会跳过并报可操作错误,不崩溃。
> - **业务代码不需要改一行**——公开 SDK 接口零改动,只需用新版 `plugindev` 重编。
> - `plg.json` 的 `entry` 字段对 Go 插件**已无意义**(写着 `plugin.so` 也无妨),
> 它现在只用于区分 Lua 插件。
> - 产物不再需要 cgo交叉编译无需目标平台 C 工具链。
> - Windows 从「只下发 3 个 stage 字段、无写回」升级到 16 字段全可见 + 写回,
> 与 Unix 共用同一套 RPC 实现。
### 构建目标与多平台打包bundle
@ -269,7 +281,7 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
}
```
编译时 `plugindev build` 根据目标平台自动生成 C ABI bridge 代码(`z_bridge_gen.go` + `z_entry.c`无需手动编写。Windows DLL 和 Linux/macOS .so 共享同一入口
编译时 `plugindev build` 自动生成子进程运行时代码(`z_proc_gen.go` 平台无关 + `z_proc_shm_unix.go` / `z_proc_shm_windows.go` 平台特定),无需手动编写。三平台共享同一入口与同一套 RPC 逻辑仅跨进程资源传递机制不同Unix 继承 fdWindows 命名内核对象)
### PluginSDK 核心 API
@ -321,7 +333,7 @@ s.RegisterTool("weather_query", sdk.ToolDef{
└── 否 → 正常记忆,无需额外处理
```
> **注意**`Cleaner` 是 Go `func` 类型(`json:"-"`),不能跨 C ABI 边界序列化,因此 C/C++/Rust 等远程插件无法使用。**Lua 插件不受此限**def 表中直接传 Lua 函数即可(`cleaner = function(text) return text end`Go 桥接层会在计算层调用时逐次回调 Lua。
> **注意**`Cleaner` 是 Go `func` 类型(`json:"-"`),不能跨进程序列化,因此 C/C++/Rust 等远程插件无法使用。**Lua 插件不受此限**def 表中直接传 Lua 函数即可(`cleaner = function(text) return text end`Go 桥接层会在计算层调用时逐次回调 Lua。
#### 阶段钩子 — 干预消息处理流
@ -526,7 +538,7 @@ Lua 插件运行在内核进程内的 gopher-lua 解释器中(单 Lua 状态 +
- **被动回调模型**`main.lua` 仅在加载时执行一次,此后插件的工具、阶段钩子、输出/输入通道、注册 API 全部由内核事件驱动回调 Lua 函数;插件不能自己启动后台任务。
- **无并发/无常驻服务能力**Lua 侧没有 goroutine、协程调度、`os`/`io` 库和 socket 监听能力,唯一主动出站通道是 `sdk.http.get/post`(同步请求)。任何阻塞循环都会持锁卡死该插件的所有调用。
- **常驻服务(如监听端口、后台轮询、定时任务)请使用 Go 插件**(工具链编译的 `.so`/`.dll`,可自行启动 goroutine参见 webui/cli 插件。Lua 插件的等价做法是事件驱动:注册工具/阶段钩子/通道由内核回调,或经 `sdk.http` 与外部进程交互。
- **常驻服务(如监听端口、后台轮询、定时任务)请使用 Go 插件**(工具链编译的 `plugin.bin`,可自行启动 goroutine参见 webui/cli 插件。Lua 插件的等价做法是事件驱动:注册工具/阶段钩子/通道由内核回调,或经 `sdk.http` 与外部进程交互。
### 插件结构
@ -575,7 +587,7 @@ lua main.lua
### Lua SDK API
Lua 插件的 `sdk.*` API 与外部插件(C ABI / 工具链编译的 `.so`/`.dll`)能力完全对齐:注册类函数调用即时报错(抛 Lua error数据类函数统一返回 `(result, err)``err` 为 nil 表示成功。核心未装配的子系统(如 SocialAPI返回空值而非报错。
Lua 插件的 `sdk.*` API 与外部插件(工具链编译的 `plugin.bin` 子进程)能力完全对齐:注册类函数调用即时报错(抛 Lua error数据类函数统一返回 `(result, err)``err` 为 nil 表示成功。核心未装配的子系统(如 SocialAPI返回空值而非报错。
**注册类**
@ -593,7 +605,7 @@ Lua 插件的 `sdk.*` API 与外部插件C ABI / 工具链编译的 `.so`/`.d
`register_stage` 的 handler 收到完整上下文(与外部插件一致):`raw_message``user_id``group_id``phase``llm_text``final_text``no_memory``response`(已响应时)、`tool_calls``tool_results`
**Stage 写回ABI v2**handler 收到的 `ctx` 是引用 table——在 handler 内直接修改可写回字段并同步至内核 `StageContext`(与 C ABI v2 外部插件能力对齐):
**Stage 写回**handler 收到的 `ctx` 是引用 table——在 handler 内直接修改可写回字段并同步至内核 `StageContext`(与子进程外部插件能力对齐):
```lua
sdk.register_stage("on_input", function(ctx)
@ -621,7 +633,7 @@ end)
| `sdk.inject_interrupt(source, channel, text)` | 中断投递 |
| `sdk.inject_text_no_memory(source, channel, text)` | 免记忆投递 |
**数据类(与 C ABI 对齐,均返回 `(result, err)`**
**数据类(与子进程外部插件对齐,均返回 `(result, err)`**
| 子表 | 函数 |
|------|------|

View File

@ -62,6 +62,14 @@ export struct SettingsPage {
@State editName: string = '';
@State showAddForm: boolean = false;
@State addFormVisible: boolean = false;
/**
* 表单当前在编辑哪条连接:空串表示新建。
*
* 之前只有"添加"入口ConnStore.updateConnection 写好了却没有任何调用者,
* 于是地址填错的连接只能删掉重建API Key 也得重敲)。同一套表单
* 靠这个 id 区分保存走 add 还是 update。
*/
@State editingId: string = '';
@State themeMode: string = 'system';
@State lang: string = 'zh';
@State bgImage: string = '';
@ -205,11 +213,12 @@ export struct SettingsPage {
this.showToast('名称和地址不能为空', true);
return;
}
if (this.editingId.length > 0) {
this.updateConnection(this.editingId, name, url, apiKey);
return;
}
connStore.addConnection(name, url, apiKey).then(() => {
this.editName = '';
this.editUrl = '';
this.editApiKey = '';
this.showAddForm = false;
this.closeConnForm();
const cur = connStore.getCurrentConnection();
if (cur !== null) {
apiClient.setConnection(cur);
@ -219,6 +228,53 @@ export struct SettingsPage {
});
}
/**
* 保存对已有连接的修改。
*
* 修改当前生效的连接后必须重新 setConnectionApiClient 持有的是
* ConnectionConfig 的引用快照,不刷新的话后续请求还会打到旧地址。
*/
private updateConnection(id: string, name: string, url: string, apiKey: string): void {
connStore.updateConnection(id, name, url, apiKey).then(() => {
this.closeConnForm();
const cur = connStore.getCurrentConnection();
if (cur !== null) {
apiClient.setConnection(cur);
}
this.loadConnections();
this.showToast('连接已更新', false);
});
}
/** 打开表单id 为空是新建非空是编辑并回填原值API Key 一并带出,避免用户重敲)。 */
private openConnForm(conn: ConnectionConfig | null): void {
this.showAddForm = true;
this.addFormVisible = false;
if (conn === null) {
this.editingId = '';
this.editName = '';
this.editUrl = '';
this.editApiKey = '';
} else {
this.editingId = conn.id;
this.editName = conn.name;
this.editUrl = conn.url;
this.editApiKey = conn.apiKey;
}
setTimeout(() => {
this.addFormVisible = true;
}, 30);
}
private closeConnForm(): void {
this.showAddForm = false;
this.addFormVisible = false;
this.editingId = '';
this.editName = '';
this.editUrl = '';
this.editApiKey = '';
}
private deleteConnection(id: string): void {
connStore.deleteConnection(id).then(() => {
this.loadConnections();
@ -840,14 +896,7 @@ export struct SettingsPage {
.backgroundColor(this.palette().accent)
.fontColor(Color.White)
.onClick(() => {
this.showAddForm = true;
this.addFormVisible = false;
this.editName = '';
this.editUrl = '';
this.editApiKey = '';
setTimeout(() => {
this.addFormVisible = true;
}, 30);
this.openConnForm(null);
})
}
.width('100%')
@ -855,6 +904,10 @@ export struct SettingsPage {
if (this.showAddForm) {
Column() {
Text(this.editingId.length > 0 ? '编辑连接' : '新建连接')
.fontSize(12)
.fontColor(this.palette().textSecondary)
.margin({ bottom: 10 })
TextInput({ placeholder: '名称 (如 HomeAgent)', text: this.editName })
.height(36).fontSize(13).fontColor(this.palette().textPrimary)
.placeholderColor(this.palette().textMuted).backgroundColor(this.palette().bgInput)
@ -888,7 +941,7 @@ export struct SettingsPage {
.border({ width: 1, color: this.palette().btnGhostBorder })
.fontColor(this.palette().textSecondary)
.onClick(() => {
this.showAddForm = false;
this.closeConnForm();
})
Blank()
Button('保存')
@ -961,6 +1014,16 @@ export struct SettingsPage {
.backgroundColor(this.palette().accentBg)
.margin({ right: 6 })
}
Button('编辑')
.height(26)
.fontSize(11)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: this.palette().btnGhostBorder })
.fontColor(this.palette().textSecondary)
.margin({ right: 6 })
.onClick(() => {
this.openConnForm(conn);
})
Button('删除')
.height(26)
.fontSize(11)
@ -980,7 +1043,10 @@ export struct SettingsPage {
color: conn.id === this.currentId ? this.palette().accent : Color.Transparent,
})
.margin({ bottom: 6 })
}, (conn: ConnectionConfig) => conn.id)
// 键里带上 name/urlForEach 对相同键只更新绑定、不重跑 @Builder 体,
// 只用 id 做键时改完地址这一行还显示旧值。行内没有 TextInput
// 因此把可变字段放进键不会有"编辑时焦点被销毁"的副作用。
}, (conn: ConnectionConfig) => conn.id + '|' + conn.name + '|' + conn.url)
}
}

155
docs/git-branching.md Normal file
View File

@ -0,0 +1,155 @@
# Git 分支管理规范
> 生效2026-08-31。适用**本仓TrueAgent/HomeAgent与 third_party/homeagent-sdkSDK 仓)**——两仓协作时分支策略必须一致,本规范两仓同用。
> 核心原则一句话:**main 唯一长命、永远可部署;一切新工作在特性分支;版本发布走 release 分支 + taghotfix 只进 released 分支并 cherry-pick 回 main。**
---
## 一、分支类型总览
| 分支 | 生命周期 | 来源 | 去向 | 部署性 |
|---|---|---|---|---|
| `main` | **唯一长命分支** | — | — | ✅ **永远可部署** |
| `feature/xxx` | 短命(本次特性完成即删) | main | 合回 main | ❌ 不部署 |
| `release/vX.Y.Z` | 中命(从切出到下个版本发布) | main | 打 tag → 构建发布 | ✅ **发布产物来源** |
| hotfix直接提交 release 分支) | 随 release 分支 | release 分支 | **cherry-pick 回 main** | ✅ |
```
main ──────────────── E ──────────────── G ────────────────(永远可部署)
│ ▲
│ feature/xxx │ cherry-pickhotfix 逐个 pick 回)
├── A ── B ──(合回)───────────────────┤
│ │
└── release/v1.2.0 release/v1.2.0
├─(tag v1.2.0)→ 构建发布 ├─(hotfix) F ← 版本特定严重 bug
└─ 退役(可删可留) └─ F 被 separately cherry-pick 到 main
```
---
## 二、分支职责
### 1. `main`(唯一长命分支)
- **唯一长期存在且永远可部署**。任何时刻 `git checkout main` 出来都是可构建、可上线的状态。
- 积攒**下一个版本**的功能feature 分支完成即合回main 持续向前。
- **main 上不直接开发**。所有改动经 feature 分支合入hotfix 经 cherry-pick 注入。
- 合入门禁(**单人直推也遵守**,不强制 PR 但强制验证):
- `make test` 全绿
- 涉及插件/工具链时:接口冻结检查 `git diff third_party/homeagent-sdk/sdk/` 为空
- `go vet ./...` 无新增告警
### 2. `feature/xxx`(新特性/修复)
- 命名:`feature/<短横线描述>`,如 `feature/plugin-proc-migration``feature/webui-narrow-fix`
- **从 main 开出**`git checkout -b feature/xxx main`
- 完成后合回 main
- 单人:直推(`git merge --no-ff` 保留特性边界,或 squash 成一个 commit二选一在团队内固定
- 多人:走 PRreview 后合入)。
- 合回后删除 feature 分支(避免累积)。
### 3. `release/vX.Y.Z`(发布)
- **从 main 的某个可部署点切出**`git checkout -b release/v1.2.0 main`
- 切出后**冻结功能**——release 分支上只做:版本号 bump、发布准备、bug 修复、文档。
- 打 tag → 构建发布安装包 → 上传(附件命名规范见历史记录)。
- **现网部署永远用 release tag 的构建产物**,不是 main 头部、更不是 feature。
### 4. hotfix只属于此版本的严重 bug
- **场景**:版本已发布后,发现只存在于该版本(或该发布线)的严重 bug。
- **动作**:直接把修复提交到 **release 分支**(不收进 main 的开发流)→ 该 release 分支重新构建、打 patch tag`v1.2.1`)发布。
- **关键hotfix 必须 cherry-pick 回 main**
```bash
# 在 release 分支上提交修复(代码部分与版本号 bump 分开提交)
git commit -m "fix(x): ..." # ① 修复本身
git commit -m "chore: bump v1.2.1" # ② 版本号(此 commit 不 pick 回 main
# 回到 main只挑修复本身
git checkout main
git cherry-pick <修复commit的sha> # 只 pick ①,不 pick ②
```
> **为什么 cherry-pick 而不是 merge**release 分支只承载该版本特有的补丁merge 会把 release 分支的版本号/发布相关改动一并带进 main 造成冲突。逐个 cherry-pick 修复 commit 让 main 精确地只获得修复本身。**版本号 bump 不要 pick 回 main**main 的版本号应始终是下一个未发布版本)。
- **hotfix 已逐个 pick 回 main ⇒ main 已含全部修复 ⇒ 无需再合并 release 回 main**。这是本规范刻意为之——除非 release 分支上有 main 想要的**功能级**改动(罕见),否则 release 永不 merge 回 main。
### 5. release 分支退役
- **下个版本发布 = 此 release 分支生命周期结束**(不再维护)。
- 退役后可删可留:
- 删除保持仓库干净tag 已保留全部历史,删分支不丢东西)。
- 保留:便于追溯该发布线的历史构建(对 24/7 现网友好,推荐与本仓库一样保留已打 tag 的历史分支做对照)。
- 本仓对现网多代版本并行维护时,保留近期 release 分支是合理的。
---
## 三、当前分支对齐2026-08-31 执行)
### 主仓TrueAgent
| 现存分支 | 状态 | 处理 |
|---|---|---|
| `main` | `48b5c24` [origin/main] | ✅ 保持不变(规范基线) |
| `feature/plugin-proc-migration` | 原 `update``69a138c`(领先 main 5文档基线 + Part 0.1/0.2 + 本规范) | ✅ **已对齐重命名**2026-08-31 |
| `backup-local`SDK 仓) | `7092d15`ahead 3, behind 14含 `ignore example/recoverydiag` 敏感提交) | ⚠️ 遗留本地分支,功能已合入 main**保留不删**(无远端,删除即永久丢失) |
### SDK 仓homeagent-sdk
| 现存分支 | 状态 | 处理 |
|---|---|---|
| `main` | `61f307b` v1.2.0 | ✅ 保持不变 |
| `update` | `5648519`(领先 main 1Part 0.2 模板修复) | ⚠️ 与主仓 `update` 对齐重命名 |
| `backup-local` | `7092d15`ahead 3, behind 14遗留调试分支 | ⚠️ 可选清理 |
> `update` 整改工作分支按规范应为 `feature/plugin-proc-migration`多进程插件化整改8-9 周大特性)。
> 是否重命名由执行人确认;不重命名则视为偏离规范的既有分支,须在文档记录其存在。
---
## 四、现网部署与版本对应(运维纪律)
- **现网 homed 永远部署 `release/vX.Y.Z` 分支打出的 tag 构建**,路径见 `Makefile``make build` → `build/homed`)。
- systemd 服务(`/usr/local/bin/homed`)替换前:备份旧二进制 → 停服 → 替换 → 起服 → 健康检查(`scripts/verify_deploy.sh`)。
- **改造期间update 整改)现网不得部署 main 或 feature 的中间态**——只有发版才用 release。
- 涉及 SDK 仓时:主仓 `go.mod` 的 `replace => ./third_party/homeagent-sdk` 指向本地 vendored 副本,
发版前确认 vendored SDK 与 SDK 仓 release tag 一致(两仓版本对齐是第一优先级)。
---
## 五、快速参考命令
```bash
# 新特性
git checkout main && git pull
git checkout -b feature/xxx
# ... 开发 ...
git checkout main && git merge --no-ff feature/xxx # 或 squash
git branch -d feature/xxx
# 发布
git checkout -b release/v1.2.0 main
git commit -am "chore: bump v1.2.0" # 版本号
git tag v1.2.0
# ... 构建发布 ...
# hotfix发布后
git checkout release/v1.2.0
git commit -am "fix(x): 严重 bug" # ① 修复
git commit -am "chore: bump v1.2.1" # ② 版本号
git tag v1.2.1
git checkout main
git cherry-pick <修复①的sha> # ③ 只挑修复
# release 退役(可选)
git branch -d release/v1.2.0 # tag 已保存历史,删分支不丢东西
```
---
## 六、本规范与「接口冻结」约束的关系
- feature 分支合回 main 的门禁(`git diff sdk/` 为空)是本仓特有的硬约束,独立于 Git 流程本身。
- 插件多进程化整改(`feature/plugin-proc-migration` 或现 `update`**不满足接口冻结不等于不能合并**——
接口冻结约束的是「公开 SDK 不变」,整改若突破需走变更评审(见 `docs/zh/plugin-interface-matrix.md` §七)。

View File

@ -0,0 +1,72 @@
//go:build ignore
package main
/*
#cgo LDFLAGS: -ldl
#include <dlfcn.h>
#include <stdlib.h>
typedef const char* (*verfn)(void);
static const char* call_ver(void* f){ return ((verfn)f)(); }
*/
import "C"
import (
"fmt"
"os"
"unsafe"
)
func main() {
// Go 用 dlopen 加载纯 C shimshim 本身常驻,无所谓)
sp := C.CString("./shim.so")
shim := C.dlopen(sp, C.RTLD_NOW|C.RTLD_LOCAL)
C.free(unsafe.Pointer(sp))
if shim == nil {
fmt.Println("shim 加载失败:", C.GoString(C.dlerror()))
os.Exit(1)
}
openName := C.CString("shim_open")
closeName := C.CString("shim_close")
symName := C.CString("shim_sym")
shimOpen := C.dlsym(shim, openName)
shimClose := C.dlsym(shim, closeName)
shimSym := C.dlsym(shim, symName)
C.free(unsafe.Pointer(openName))
C.free(unsafe.Pointer(closeName))
C.free(unsafe.Pointer(symName))
fmt.Printf("shim 就绪: open=%p close=%p sym=%p\n\n", shimOpen, shimClose, shimSym)
// 直接用 dlopen/dlsym 调 shim 的三个函数(避免再写一层 C 包装)
load := func(path string) unsafe.Pointer {
cp := C.CString(path)
defer C.free(unsafe.Pointer(cp))
return C.dlopen(cp, C.RTLD_NOW|C.RTLD_LOCAL)
}
ver := func(h unsafe.Pointer) string {
n := C.CString("probe_version")
defer C.free(unsafe.Pointer(n))
f := C.dlsym(h, n)
if f == nil { return "<no sym>" }
return C.GoString(C.call_ver(f))
}
fmt.Println("--- 场景: Go(带 NODELETE runtime) 加载/卸载纯 C 的第三层 so ---")
h1 := load("./probe.so")
fmt.Printf("1) dlopen probe.so handle=%p version=%s\n", h1, ver(h1))
rc := C.dlclose(h1)
fmt.Printf("2) dlclose rc=%d\n", int(rc))
// 换内容V1 -> V2同路径
in, _ := os.ReadFile("probe_v2.so")
os.WriteFile("probe.so", in, 0755)
fmt.Println("3) 磁盘 probe.so 内容替换为 V2同路径")
h2 := load("./probe.so")
fmt.Printf("4) 再 dlopen 同路径 handle=%p version=%s\n", h2, ver(h2))
if h1 == h2 {
fmt.Println(" => 句柄相同:未卸载,仍是旧代码")
} else {
fmt.Println(" => 句柄不同:真正卸载并重新装载了新代码 ✅")
}
}

View File

@ -0,0 +1,49 @@
//go:build ignore
package main
/*
#cgo LDFLAGS: -ldl
#include <dlfcn.h>
#include <stdlib.h>
typedef void* (*openfn)(const char*);
typedef int (*closefn)(void*);
static void* c_open(void* f, const char* p){ return ((openfn)f)(p); }
static int c_close(void* f, void* h){ return ((closefn)f)(h); }
*/
import "C"
import (
"fmt"
"os"
"strings"
"unsafe"
)
func cnt(s string) int {
b, _ := os.ReadFile("/proc/self/maps")
n := 0
for _, l := range strings.Split(string(b), "\n") { if strings.Contains(l, s) { n++ } }
return n
}
func main() {
sp := C.CString("./shim.so")
shim := C.dlopen(sp, C.RTLD_NOW|C.RTLD_LOCAL)
C.free(unsafe.Pointer(sp))
no := C.CString("shim_open"); nc := C.CString("shim_close")
fo := C.dlsym(shim, no); fc := C.dlsym(shim, nc)
C.free(unsafe.Pointer(no)); C.free(unsafe.Pointer(nc))
// 经【纯 C shim】去 dlopen/dlclose Go c-shared 插件
qp := C.CString("/home/newqqagent/plugins/qq/plugin.so")
h := C.c_open(fo, qp)
C.free(unsafe.Pointer(qp))
fmt.Printf("经 C shim dlopen Go 插件 handle=%p 映射段=%d\n", h, cnt("qq/plugin.so"))
rc := C.c_close(fc, h)
fmt.Printf("经 C shim dlclose rc=%d 映射段=%d\n", int(rc), cnt("qq/plugin.so"))
if cnt("qq/plugin.so") > 0 {
fmt.Println("\n❌ 仍未卸载 —— NODELETE 属于目标 .so 本身,与谁调 dlopen 无关")
} else {
fmt.Println("\n✅ 卸载成功")
}
}

View File

@ -0,0 +1,58 @@
//go:build ignore
package main
/*
#cgo LDFLAGS: -ldl
#include <dlfcn.h>
#include <stdlib.h>
typedef char* (*verfn)(void);
static char* call_ver(void* f){ return ((verfn)f)(); }
*/
import "C"
import (
"fmt"
"os"
"strings"
"unsafe"
)
func threads() int {
e, _ := os.ReadDir("/proc/self/task")
return len(e)
}
func rss() int {
b, _ := os.ReadFile("/proc/self/status")
for _, l := range strings.Split(string(b), "\n") {
if strings.HasPrefix(l, "VmRSS:") {
var k int
fmt.Sscanf(l, "VmRSS: %d kB", &k)
return k
}
}
return 0
}
func main() {
base, baseT := rss(), threads()
fmt.Printf("基线: RSS=%dKB threads=%d\n\n", base, baseT)
src, _ := os.ReadFile("glv1.so")
os.MkdirAll("stress", 0755)
var hs []unsafe.Pointer
for i := 1; i <= 30; i++ {
p := fmt.Sprintf("stress/%010d-qq.so", 1700000000+i)
os.WriteFile(p, src, 0755)
cp := C.CString("./" + p)
h := C.dlopen(cp, C.RTLD_NOW|C.RTLD_LOCAL)
C.free(unsafe.Pointer(cp))
if h == nil { fmt.Printf("第 %d 次失败\n", i); break }
hs = append(hs, h)
C.dlclose(h) // 模拟每次都尝试卸载no-op
if i%10 == 0 {
fmt.Printf("第 %2d 次重载: RSS=%dKB (+%dKB) threads=%d (+%d)\n",
i, rss(), rss()-base, threads(), threads()-baseT)
}
}
fmt.Printf("\n30 次重载后: RSS 增长 %dKB, 线程增长 %d\n", rss()-base, threads()-baseT)
fmt.Printf("每次重载均摊: RSS +%.1fKB, 线程 +%.2f\n",
float64(rss()-base)/30, float64(threads()-baseT)/30)
}

View File

@ -0,0 +1,2 @@
#include <stdio.h>
const char* probe_version(void){ return "V1"; }

View File

@ -0,0 +1,2 @@
#include <stdio.h>
const char* probe_version(void){ return "V2"; }

View File

@ -0,0 +1,9 @@
#include <dlfcn.h>
#include <stdio.h>
void* shim_open(const char* p){
void* h = dlopen(p, RTLD_NOW|RTLD_LOCAL);
if(!h) printf(" [shim] open FAIL: %s\n", dlerror());
return h;
}
int shim_close(void* h){ return dlclose(h); }
void* shim_sym(void* h, const char* n){ return dlsym(h, n); }

View File

@ -0,0 +1,49 @@
//go:build ignore
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"time"
"golang.org/x/sys/unix"
)
func main() {
fmt.Println("=== 实验 10多媒体 payload —— 共享内存零拷贝 vs JSON base64 ===")
sizes := []int{100 * 1024, 1024 * 1024, 5 * 1024 * 1024}
for _, sz := range sizes {
img := make([]byte, sz)
for i := range img { img[i] = byte(i % 251) }
// A. JSON + base64当前 ContentBlock 的做法)
t0 := time.Now()
b64 := base64.StdEncoding.EncodeToString(img)
blob, _ := json.Marshal(map[string]string{"type": "image_url", "url": "data:image/png;base64," + b64})
var back map[string]string
json.Unmarshal(blob, &back)
dec, _ := base64.StdEncoding.DecodeString(back["url"][22:])
jsonDur := time.Since(t0)
// B. 共享内存 arena写入 + 偏移解引用,零拷贝读)
mfd, _ := unix.MemfdCreate("arena", 0)
unix.Ftruncate(mfd, int64(sz+4096))
data, _ := unix.Mmap(mfd, 0, sz+4096, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
t0 = time.Now()
copy(data[4096:], img) // 写 arena
view := data[4096 : 4096+sz] // 偏移解引用 = 零拷贝切片
_ = view[sz-1]
shmDur := time.Since(t0)
unix.Munmap(data)
unix.Close(mfd)
fmt.Printf("\n%s payload:\n", map[int]string{100*1024:"100KB", 1024*1024:"1MB", 5*1024*1024:"5MB"}[sz])
fmt.Printf(" A JSON+base64: %8v 传输体积 %d B (+%.0f%%) 解出 %d B %s\n",
jsonDur, len(blob), float64(len(blob)-sz)/float64(sz)*100, len(dec),
map[bool]string{true:"✓",false:"✗"}[len(dec)==sz])
fmt.Printf(" B 共享内存: %8v 传输体积 8 B (描述符) 零拷贝视图 %d B\n", shmDur, len(view))
fmt.Printf(" → 加速 %.0fx, 体积节省 %.0f%%\n",
float64(jsonDur)/float64(shmDur), float64(len(blob)-8)/float64(len(blob))*100)
}
}

View File

@ -0,0 +1,42 @@
//go:build ignore
package main
import (
"bufio"
"encoding/json"
"fmt"
"os/exec"
"sort"
"time"
)
type Req struct{ ID int `json:"id"`; Method string `json:"method"`; Args json.RawMessage `json:"args"` }
type Res struct{ ID int `json:"id"`; Result string `json:"result"` }
func main() {
fmt.Println("=== 实验 11工具调用 RPC 端到端延迟(实测 payload 中位 93B===")
cmd := exec.Command("./plug11")
sin, _ := cmd.StdinPipe(); sout, _ := cmd.StdoutPipe()
cmd.Start()
enc := json.NewEncoder(bufio.NewWriter(sin))
w := bufio.NewWriter(sin); enc = json.NewEncoder(w)
dec := json.NewDecoder(bufio.NewReader(sout))
args := json.RawMessage(`{"city":"hangzhou","days":3,"unit":"celsius","detail":true}`)
const N = 10000
lat := make([]time.Duration, 0, N)
for i := 0; i < N; i++ {
t0 := time.Now()
enc.Encode(Req{ID: i, Method: "weather_query", Args: args}); w.Flush()
var r Res
if err := dec.Decode(&r); err != nil { break }
lat = append(lat, time.Since(t0))
}
sin.Close(); cmd.Wait()
sort.Slice(lat, func(a,b int) bool { return lat[a] < lat[b] })
p := func(q float64) time.Duration { return lat[int(float64(len(lat))*q)] }
fmt.Printf("样本 %d 次\n", len(lat))
fmt.Printf(" p50 = %v\n p90 = %v\n p99 = %v\n max = %v\n", p(0.5), p(0.9), p(0.99), lat[len(lat)-1])
fmt.Printf("\n对照 LLM 单轮往返 2-8 秒 → RPC 占比 ≈ %.5f%%\n",
float64(p(0.5))/float64(3*time.Second)*100)
}

View File

@ -0,0 +1,12 @@
//go:build ignore
package main
import ("bufio";"encoding/json";"os")
type Req struct{ ID int `json:"id"`; Method string `json:"method"`; Args json.RawMessage `json:"args"` }
type Res struct{ ID int `json:"id"`; Result string `json:"result"` }
func main(){
dec:=json.NewDecoder(bufio.NewReader(os.Stdin))
w:=bufio.NewWriter(os.Stdout); enc:=json.NewEncoder(w)
for { var q Req
if err:=dec.Decode(&q); err!=nil {return}
enc.Encode(Res{ID:q.ID, Result:`{"ok":true,"data":"` + string(q.Args) + `"}`}); w.Flush() }
}

View File

@ -0,0 +1,58 @@
//go:build ignore
package main
import (
"fmt"
"os"
"runtime"
"sync"
"sync/atomic"
"time"
"golang.org/x/sys/unix"
)
func threads() int { e, _ := os.ReadDir("/proc/self/task"); return len(e) }
func main() {
fmt.Println("=== 实验 1eventfd 是否走 Go netpoller只 park goroutine 不占 OS 线程)===")
base := threads()
fmt.Printf("基线线程数: %d (GOMAXPROCS=%d)\n\n", base, runtime.GOMAXPROCS(0))
const N = 200 // 模拟 200 个订阅者等待
var wg sync.WaitGroup
var woke int64
files := make([]*os.File, N)
for i := 0; i < N; i++ {
efd, err := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
if err != nil { fmt.Println("eventfd 失败:", err); return }
f := os.NewFile(uintptr(efd), fmt.Sprintf("evt%d", i))
files[i] = f
wg.Add(1)
go func(f *os.File) {
defer wg.Done()
buf := make([]byte, 8)
// 阻塞读:若走 netpoller 只 park goroutine
if _, err := f.Read(buf); err == nil {
atomic.AddInt64(&woke, 1)
}
}(f)
}
time.Sleep(500 * time.Millisecond) // 让所有 goroutine 进入等待
waiting := threads()
fmt.Printf("%d 个 goroutine 阻塞在 eventfd.Read 后:\n", N)
fmt.Printf(" 线程数 = %d (增长 %d)\n", waiting, waiting-base)
if waiting-base < 20 {
fmt.Println(" ✅ 走 netpoller线程未随等待者数量增长")
} else {
fmt.Printf(" ❌ 退化为阻塞 syscall每个等待者占一个 OS 线程\n")
}
// 全部唤醒
one := []byte{1,0,0,0,0,0,0,0}
for _, f := range files { f.Write(one) }
wg.Wait()
fmt.Printf("\n唤醒数 = %d/%d 唤醒后线程数 = %d\n", woke, N, threads())
}

View File

@ -0,0 +1,41 @@
//go:build ignore
package main
import (
"encoding/binary"
"fmt"
"os"
"unsafe"
"golang.org/x/sys/unix"
)
// 子进程fd 3 = eventfd(通知), fd 4 = shm 文件
func main() {
efd := os.NewFile(3, "evt")
shmf := os.NewFile(4, "shm")
data, err := unix.Mmap(int(shmf.Fd()), 0, 4096, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
if err != nil { fmt.Println("CHILD mmap 失败:", err); os.Exit(1) }
fmt.Printf("CHILD: mmap 基址 = %p\n", unsafe.Pointer(&data[0]))
buf := make([]byte, 8)
if _, err := efd.Read(buf); err != nil {
fmt.Println("CHILD read err:", err); os.Exit(1)
}
n := binary.LittleEndian.Uint64(buf)
fmt.Printf("CHILD: 被 eventfd 唤醒, 计数=%d\n", n)
// 按偏移读:头部 16 字节 = {off uint32, len uint32, seq uint64}
off := binary.LittleEndian.Uint32(data[0:4])
ln := binary.LittleEndian.Uint32(data[4:8])
seq := binary.LittleEndian.Uint64(data[8:16])
payload := string(data[off : off+ln])
fmt.Printf("CHILD: 偏移解引用 off=%d len=%d seq=%d → %q\n", off, ln, seq, payload)
// 子进程回写(验证双向可见)
copy(data[2048:], []byte("CHILD-ACK"))
binary.LittleEndian.PutUint32(data[16:20], 2048)
binary.LittleEndian.PutUint32(data[20:24], uint32(len("CHILD-ACK")))
fmt.Println("CHILD: 已回写 ACK")
}

View File

@ -0,0 +1,60 @@
//go:build ignore
package main
import (
"encoding/binary"
"fmt"
"os"
"os/exec"
"time"
"unsafe"
"golang.org/x/sys/unix"
)
func main() {
fmt.Println("=== 实验 2跨进程 eventfd 通知 + 共享内存偏移解引用 ===")
// eventfd 不带 CLOEXEC需要被子进程继承
efd, err := unix.Eventfd(0, unix.EFD_NONBLOCK)
if err != nil { panic(err) }
evtFile := os.NewFile(uintptr(efd), "evt")
// shm: 用 memfd匿名无需 /dev/shm 清理)
mfd, err := unix.MemfdCreate("stagectx", 0)
if err != nil { panic(err) }
if err := unix.Ftruncate(mfd, 4096); err != nil { panic(err) }
shmFile := os.NewFile(uintptr(mfd), "shm")
data, err := unix.Mmap(mfd, 0, 4096, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
if err != nil { panic(err) }
fmt.Printf("PARENT: mmap 基址 = %p\n", unsafe.Pointer(&data[0]))
// 写 payload 到 arena(偏移 1024),头部记描述符
msg := "hello-from-parent-via-offset"
copy(data[1024:], []byte(msg))
binary.LittleEndian.PutUint32(data[0:4], 1024)
binary.LittleEndian.PutUint32(data[4:8], uint32(len(msg)))
binary.LittleEndian.PutUint64(data[8:16], 42)
fmt.Printf("PARENT: 数据已落地 arena@1024, 描述符 {off:1024, len:%d, seq:42}\n", len(msg))
cmd := exec.Command("go", "run", "exp2_child.go")
cmd.ExtraFiles = []*os.File{evtFile, shmFile} // → 子进程 fd 3, 4
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Start(); err != nil { panic(err) }
time.Sleep(3 * time.Second) // 等 go run 编译+启动
fmt.Println("PARENT: 数据到位后 post eventfd不等待消费者")
t0 := time.Now()
evtFile.Write([]byte{1,0,0,0,0,0,0,0})
fmt.Printf("PARENT: post 耗时 %v ← post-and-forget\n", time.Since(t0))
cmd.Wait()
// 读子进程回写
off := binary.LittleEndian.Uint32(data[16:20])
ln := binary.LittleEndian.Uint32(data[20:24])
if ln > 0 {
fmt.Printf("PARENT: 读到子进程回写 → %q ✅ 双向可见\n", string(data[off:off+ln]))
}
}

View File

@ -0,0 +1,31 @@
//go:build ignore
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"time"
)
type req struct{ ID int `json:"id"`; Method string `json:"method"` }
type resp struct{ ID int `json:"id"`; OK bool `json:"ok"` }
func main() {
in := bufio.NewReader(os.Stdin)
out := bufio.NewWriter(os.Stdout)
enc, dec := json.NewEncoder(out), json.NewDecoder(in)
const N = 20000
t0 := time.Now()
for i := 0; i < N; i++ {
enc.Encode(req{ID: i, Method: "stage.lock"})
out.Flush()
var r resp
if err := dec.Decode(&r); err != nil { fmt.Fprintln(os.Stderr, "dec:", err); return }
}
d := time.Since(t0)
fmt.Fprintf(os.Stderr, "CHILD: %d 次 lock RPC 往返 用时 %v, 均摊 %.2f µs/次\n",
N, d, float64(d.Microseconds())/float64(N))
}

View File

@ -0,0 +1,37 @@
//go:build ignore
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"os/exec"
"sync"
)
type req struct{ ID int `json:"id"`; Method string `json:"method"` }
type resp struct{ ID int `json:"id"`; OK bool `json:"ok"` }
func main() {
fmt.Println("=== 实验 3锁仲裁 RPC 往返成本stdio JSON-RPC===")
cmd := exec.Command("go", "run", "exp3_child.go")
stdin, _ := cmd.StdinPipe()
stdout, _ := cmd.StdoutPipe()
cmd.Stderr = os.Stderr
cmd.Start()
var mu sync.Mutex // 内核侧真实的锁仲裁
dec := json.NewDecoder(bufio.NewReader(stdout))
w := bufio.NewWriter(stdin)
enc := json.NewEncoder(w)
for {
var q req
if err := dec.Decode(&q); err != nil { break }
mu.Lock() // 真实加锁
mu.Unlock() // 立即释放(模拟仲裁开销)
enc.Encode(resp{ID: q.ID, OK: true})
w.Flush()
}
cmd.Wait()
}

View File

@ -0,0 +1,71 @@
//go:build ignore
package main
import (
"fmt"
"os"
"sync/atomic"
"time"
"golang.org/x/sys/unix"
)
type ring struct {
writeSeq atomic.Uint64
cap uint64
slots []uint64
}
func main() {
fmt.Println("=== 实验 4事件环 post-and-forget vs 同步 Publish慢消费者场景===")
const tokens = 5000
// --- A. 现状:同步 Publish消费者慢 ---
slowHandler := func() { time.Sleep(20 * time.Microsecond) }
t0 := time.Now()
for i := 0; i < tokens; i++ { slowHandler() }
syncDur := time.Since(t0)
fmt.Printf("A 同步 Publish (慢消费者 20µs): %d token 耗时 %v → 均摊 %.1f µs/token\n",
tokens, syncDur, float64(syncDur.Microseconds())/tokens)
// --- B. 新方案:写环 + eventfd post不等消费者 ---
r := &ring{cap: 1024, slots: make([]uint64, 1024)}
efd, _ := unix.Eventfd(0, unix.EFD_NONBLOCK)
f := os.NewFile(uintptr(efd), "e")
var dropped atomic.Uint64
// 慢消费者 goroutine
done := make(chan struct{})
go func() {
buf := make([]byte, 8)
var readSeq uint64
for {
if _, err := f.Read(buf); err != nil { return }
w := r.writeSeq.Load()
if w-readSeq > r.cap {
dropped.Add(w - readSeq - r.cap)
readSeq = w - r.cap
}
for readSeq < w { readSeq++ }
time.Sleep(20 * time.Microsecond) // 慢
select { case <-done: return; default: }
}
}()
t0 = time.Now()
one := []byte{1,0,0,0,0,0,0,0}
for i := 0; i < tokens; i++ {
s := r.writeSeq.Add(1)
r.slots[s%r.cap] = s // 写数据
f.Write(one) // post不等
}
asyncDur := time.Since(t0)
close(done)
fmt.Printf("B 环+eventfd post: %d token 耗时 %v → 均摊 %.2f µs/token\n",
tokens, asyncDur, float64(asyncDur.Microseconds())/tokens)
fmt.Printf("\n加速比 %.1fx 丢弃事件 %d消费者跟不上已计数\n",
float64(syncDur)/float64(asyncDur), dropped.Load())
if asyncDur < syncDur/5 {
fmt.Println("✅ post-and-forget 使流式发布与消费者速度解耦")
}
}

View File

@ -0,0 +1,55 @@
//go:build ignore
package main
import (
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
func pssKB(pid int) int {
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/smaps_rollup", pid))
if err != nil { return 0 }
for _, l := range strings.Split(string(b), "\n") {
if strings.HasPrefix(l, "Pss:") {
f := strings.Fields(l)
n, _ := strconv.Atoi(f[1]); return n
}
}
return 0
}
func threads(pid int) int {
e, _ := os.ReadDir(fmt.Sprintf("/proc/%d/task", pid)); return len(e)
}
func main() {
fmt.Println("=== 实验 517 个 Go 子进程插件的真实常驻开销PSS 计入共享页去重)===")
var cmds []*exec.Cmd
for i := 0; i < 17; i++ {
c := exec.Command("./plugbin")
c.Stdin, _ = os.Open(os.DevNull)
if err := c.Start(); err != nil { fmt.Println("start:", err); return }
cmds = append(cmds, c)
}
time.Sleep(1500 * time.Millisecond)
totalPss, totalThreads := 0, 0
for _, c := range cmds {
totalPss += pssKB(c.Process.Pid)
totalThreads += threads(c.Process.Pid)
}
fmt.Printf("17 进程合计: PSS = %.1f MB, 线程 = %d\n", float64(totalPss)/1024, totalThreads)
fmt.Printf("单进程均摊: PSS = %.2f MB, 线程 = %.1f\n",
float64(totalPss)/1024/17, float64(totalThreads)/17)
fmt.Printf("\n对照 homed 当前(单进程装 17 个 .so:\n")
// 找 homed
out, _ := exec.Command("pgrep", "-x", "homed").Output()
if p := strings.TrimSpace(string(out)); p != "" {
pid, _ := strconv.Atoi(strings.Fields(p)[0])
fmt.Printf(" homed PSS = %.1f MB, 线程 = %d\n", float64(pssKB(pid))/1024, threads(pid))
}
for _, c := range cmds { c.Process.Kill(); c.Wait() }
}

View File

@ -0,0 +1,23 @@
//go:build ignore
package main
import (
"bufio"
"encoding/json"
"os"
)
// 模拟一个最小插件stdio JSON-RPC loop + 一个 goroutine
func main() {
go func() { select {} }()
in := bufio.NewReader(os.Stdin)
dec := json.NewDecoder(in)
out := bufio.NewWriter(os.Stdout)
enc := json.NewEncoder(out)
for {
var m map[string]interface{}
if err := dec.Decode(&m); err != nil { return }
enc.Encode(map[string]interface{}{"ok": true})
out.Flush()
}
}

View File

@ -0,0 +1,68 @@
//go:build ignore
package main
import (
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
func pssKB(pid int) int {
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/smaps_rollup", pid))
if err != nil { return -1 }
for _, l := range strings.Split(string(b), "\n") {
if strings.HasPrefix(l, "Pss:") { f := strings.Fields(l); n,_ := strconv.Atoi(f[1]); return n }
}
return -1
}
func rssKB(pid int) int {
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
if err != nil { return -1 }
for _, l := range strings.Split(string(b), "\n") {
if strings.HasPrefix(l, "VmRSS:") { f := strings.Fields(l); n,_ := strconv.Atoi(f[1]); return n }
}
return -1
}
func threads(pid int) int { e,_ := os.ReadDir(fmt.Sprintf("/proc/%d/task", pid)); return len(e) }
func main() {
fmt.Println("=== 实验 5b17 个 Go 子进程常驻开销(保持 stdin 管道存活)===")
var cmds []*exec.Cmd
var pipes []interface{ Close() error }
for i := 0; i < 17; i++ {
c := exec.Command("./plugbin")
w, _ := c.StdinPipe() // 保持打开 → 不 EOF
pipes = append(pipes, w)
c.Stdout = nil
if err := c.Start(); err != nil { fmt.Println(err); return }
cmds = append(cmds, c)
}
time.Sleep(2 * time.Second)
tp, tr, tt, alive := 0, 0, 0, 0
for _, c := range cmds {
pid := c.Process.Pid
if _, err := os.Stat(fmt.Sprintf("/proc/%d", pid)); err != nil { continue }
alive++
if v := pssKB(pid); v > 0 { tp += v }
if v := rssKB(pid); v > 0 { tr += v }
tt += threads(pid)
}
fmt.Printf("存活进程 %d/17\n", alive)
fmt.Printf("合计: PSS=%.1f MB RSS=%.1f MB 线程=%d\n",
float64(tp)/1024, float64(tr)/1024, tt)
if alive > 0 {
fmt.Printf("均摊: PSS=%.2f MB RSS=%.2f MB 线程=%.1f\n",
float64(tp)/1024/float64(alive), float64(tr)/1024/float64(alive), float64(tt)/float64(alive))
}
out, _ := exec.Command("pgrep", "-x", "homed").Output()
if p := strings.TrimSpace(string(out)); p != "" {
pid, _ := strconv.Atoi(strings.Fields(p)[0])
fmt.Printf("\n对照 homed单进程 + 17 个 .so: RSS=%.1f MB 线程=%d\n",
float64(rssKB(pid))/1024, threads(pid))
}
for _, c := range cmds { c.Process.Kill(); c.Wait() }
}

View File

@ -0,0 +1,49 @@
//go:build ignore
package main
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"time"
)
func main() {
fmt.Println("=== 实验 6子进程崩溃隔离 + 退出码/EOF 作为 recordCrash 信号 ===")
cmd := exec.Command("./crashbin")
sin, _ := cmd.StdinPipe()
sout, _ := cmd.StdoutPipe()
cmd.Stderr = nil // 丢弃 panic 栈
cmd.Start()
fmt.Printf("插件进程 pid=%d 已启动\n", cmd.Process.Pid)
enc := json.NewEncoder(sin)
dec := json.NewDecoder(bufio.NewReader(sout))
// 正常调用
enc.Encode(map[string]string{"method": "ping"})
var r map[string]interface{}
if err := dec.Decode(&r); err == nil { fmt.Println("正常调用 → ", r) }
// 触发崩溃
fmt.Println("\n发送 boom插件内 panic...")
t0 := time.Now()
enc.Encode(map[string]string{"method": "boom"})
err := dec.Decode(&r)
detected := "未检测到"
if errors.Is(err, io.EOF) || err == io.ErrUnexpectedEOF { detected = "EOF" } else if err != nil { detected = fmt.Sprintf("%v", err) }
fmt.Printf("调用侧感知: %s (耗时 %v)\n", detected, time.Since(t0))
werr := cmd.Wait()
var ec int = -1
if ee, ok := werr.(*exec.ExitError); ok { ec = ee.ExitCode() }
fmt.Printf("进程退出码 = %d panic → 2可直接喂 recordCrash\n", ec)
fmt.Printf("\n宿主进程仍存活: pid=%d ✅ 崩溃已隔离\n", os.Getpid())
fmt.Println("→ 对照:当前 .so 模型下bridge 兜不住的 panic 会带崩整个 homed")
}

View File

@ -0,0 +1,23 @@
//go:build ignore
package main
import (
"bufio"
"encoding/json"
"os"
)
func main() {
dec := json.NewDecoder(bufio.NewReader(os.Stdin))
out := bufio.NewWriter(os.Stdout)
enc := json.NewEncoder(out)
for {
var m map[string]interface{}
if err := dec.Decode(&m); err != nil { return }
if m["method"] == "boom" {
panic("插件故意崩溃") // 真 panic
}
enc.Encode(map[string]interface{}{"ok": true})
out.Flush()
}
}

View File

@ -0,0 +1,63 @@
//go:build ignore
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"os/exec"
"time"
)
func spawnAndAsk(bin string) string {
cmd := exec.Command(bin)
sin, _ := cmd.StdinPipe()
sout, _ := cmd.StdoutPipe()
cmd.Start()
enc := json.NewEncoder(sin)
dec := json.NewDecoder(bufio.NewReader(sout))
enc.Encode(map[string]string{"method": "version"})
var r map[string]interface{}
dec.Decode(&r)
sin.Close()
cmd.Process.Kill()
cmd.Wait()
if v, ok := r["version"].(string); ok { return v }
return "?"
}
func build(ver, out string) {
src := fmt.Sprintf(`package main
import ("bufio";"encoding/json";"os")
func main(){
dec:=json.NewDecoder(bufio.NewReader(os.Stdin))
w:=bufio.NewWriter(os.Stdout); enc:=json.NewEncoder(w)
for { var m map[string]interface{}
if err:=dec.Decode(&m); err!=nil {return}
enc.Encode(map[string]string{"version":%q}); w.Flush() }
}`, ver)
os.MkdirAll("v", 0755)
os.WriteFile("v/main.go", []byte(src), 0644)
os.WriteFile("v/go.mod", []byte("module v\ngo 1.21\n"), 0644)
c := exec.Command("go", "build", "-o", "../"+out, ".")
c.Dir = "v"
if b, err := c.CombinedOutput(); err != nil { fmt.Println("build err:", string(b)) }
}
func main() {
fmt.Println("=== 实验 7子进程模型下的热重载迁移的原始目标===")
build("v1.0.0", "hotbin")
fmt.Printf("1) 首次启动插件 → version = %s\n", spawnAndAsk("./hotbin"))
fmt.Println("2) 替换二进制为 v2.0.0(同路径,无需版本化 hash 目录)")
build("v2.0.0", "hotbin")
time.Sleep(200 * time.Millisecond)
v := spawnAndAsk("./hotbin")
fmt.Printf("3) 重启插件进程 → version = %s\n", v)
if v == "v2.0.0" {
fmt.Println("\n✅ 同路径替换即生效:无 NODELETE、无版本化路径、无线程泄漏")
fmt.Println(" 对照 .so 模型:同路径 dlopen 复用旧映像,永远拿不到 v2")
}
}

View File

@ -0,0 +1,84 @@
//go:build ignore
package main
import (
"bufio"
"encoding/binary"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"sync"
"time"
"golang.org/x/sys/unix"
)
func main() {
fmt.Println("=== 实验 8跨进程并发扇出改写同一 StageContext最高风险点 3.4===")
mfd, _ := unix.MemfdCreate("stagectx", 0)
unix.Ftruncate(mfd, 65536)
shmFile := os.NewFile(uintptr(mfd), "shm")
data, _ := unix.Mmap(mfd, 0, 65536, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
// 初始 final_text = "" @1024, arena 游标 = 1024
binary.LittleEndian.PutUint32(data[0:4], 1024)
binary.LittleEndian.PutUint32(data[4:8], 0)
binary.LittleEndian.PutUint32(data[8:12], 1024)
tags := []string{"A", "B", "C", "D", "E"} // 5 个并发插件
var mu sync.Mutex // 内核侧锁仲裁
var wg sync.WaitGroup
var rpcCount int64
var cntMu sync.Mutex
t0 := time.Now()
for _, tag := range tags {
cmd := exec.Command("go", "run", "exp8_worker.go", tag)
cmd.ExtraFiles = []*os.File{shmFile}
sin, _ := cmd.StdinPipe()
sout, _ := cmd.StdoutPipe()
cmd.Stderr = os.Stderr
cmd.Start()
wg.Add(1)
go func() {
defer wg.Done()
dec := json.NewDecoder(bufio.NewReader(sout))
w := bufio.NewWriter(sin)
enc := json.NewEncoder(w)
held := false
for {
var q map[string]string
if err := dec.Decode(&q); err != nil { break }
switch q["method"] {
case "stage.lock": mu.Lock(); held = true
case "stage.unlock": if held { mu.Unlock(); held = false }
}
cntMu.Lock(); rpcCount++; cntMu.Unlock()
enc.Encode(map[string]bool{"ok": true}); w.Flush()
}
if held { mu.Unlock() }
cmd.Wait()
}()
}
wg.Wait()
dur := time.Since(t0)
off := binary.LittleEndian.Uint32(data[0:4])
ln := binary.LittleEndian.Uint32(data[4:8])
final := string(data[off : off+ln])
fmt.Printf("\n--- 结果 ---\n")
fmt.Printf("最终 final_text 长度 = %d\n", len(final))
counts := map[string]int{}
for _, t := range tags { counts[t] = strings.Count(final, t) }
fmt.Printf("各插件写入次数: %v\n", counts)
total := 0
for _, c := range counts { total += c }
fmt.Printf("总字符 = %d, 长度 = %d → %s\n", total, len(final),
map[bool]string{true:"一致 ✅ 无丢失/无撕裂", false:"不一致 ❌"}[total == len(final)])
fmt.Printf("RPC 锁操作 = %d 次, 总耗时 %v\n", rpcCount, dur)
fmt.Printf("\n注写入次数少于 5×300 是 arena 64KB 上限所致append-only 未压实),符合设计\n")
}

View File

@ -0,0 +1,50 @@
//go:build ignore
package main
import (
"bufio"
"encoding/binary"
"encoding/json"
"fmt"
"os"
"strconv"
"golang.org/x/sys/unix"
)
// 模拟插件:拿锁 → 读 final_text → 追加自己的标记 → 写回 → 放锁
// 锁通过 stdio RPC 向内核申请(方案 3.7:锁仲裁回归内核,无 cgo
func main() {
tag := os.Args[1]
shmf := os.NewFile(3, "shm")
data, err := unix.Mmap(int(shmf.Fd()), 0, 65536, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
if err != nil { fmt.Fprintln(os.Stderr, "mmap:", err); os.Exit(1) }
dec := json.NewDecoder(bufio.NewReader(os.Stdin))
w := bufio.NewWriter(os.Stdout)
enc := json.NewEncoder(w)
rpc := func(method string) {
enc.Encode(map[string]string{"method": method}); w.Flush()
var r map[string]interface{}; dec.Decode(&r)
}
const iters = 300
for i := 0; i < iters; i++ {
rpc("stage.lock")
// --- 临界区:偏移解引用读写 final_text ---
off := binary.LittleEndian.Uint32(data[0:4])
ln := binary.LittleEndian.Uint32(data[4:8])
cur := string(data[off : off+ln])
add := tag
newS := cur + add
// append-only arena写到新位置
newOff := binary.LittleEndian.Uint32(data[8:12])
if int(newOff)+len(newS) > 65536 { rpc("stage.unlock"); break }
copy(data[newOff:], []byte(newS))
binary.LittleEndian.PutUint32(data[0:4], newOff)
binary.LittleEndian.PutUint32(data[4:8], uint32(len(newS)))
binary.LittleEndian.PutUint32(data[8:12], newOff+uint32(len(newS)))
rpc("stage.unlock")
}
fmt.Fprintln(os.Stderr, "worker "+tag+" done, iters="+strconv.Itoa(iters))
}

View File

@ -0,0 +1,60 @@
//go:build ignore
package main
import (
"bufio"
"encoding/json"
"fmt"
"os/exec"
"sync"
"time"
)
func run(name, arg string, mu *sync.Mutex, crashed *bool) {
cmd := exec.Command("go", "run", "exp9_worker.go", arg)
sin, _ := cmd.StdinPipe(); sout, _ := cmd.StdoutPipe()
cmd.Stderr = nil
cmd.Start()
dec := json.NewDecoder(bufio.NewReader(sout))
w := bufio.NewWriter(sin); enc := json.NewEncoder(w)
held := false
for {
var q map[string]string
if err := dec.Decode(&q); err != nil { break }
switch q["method"] {
case "stage.lock": mu.Lock(); held = true; fmt.Printf(" [%s] 获得锁\n", name)
case "stage.unlock": if held { mu.Unlock(); held = false; fmt.Printf(" [%s] 释放锁\n", name) }
}
enc.Encode(map[string]bool{"ok":true}); w.Flush()
}
err := cmd.Wait()
// 关键:进程死了,内核侧检测到 EOF/退出 → 强制释放它持有的锁
if held {
mu.Unlock()
*crashed = true
fmt.Printf(" [%s] 进程死亡(%v),内核强制释放其持有的锁 ← 自愈\n", name, err)
}
}
func main() {
fmt.Println("=== 实验 9持锁进程崩溃后的自愈验证无需 robust pthread_mutex===")
var mu sync.Mutex
crashed := false
fmt.Println("\n1) 插件 X 拿锁后 panic:")
run("X", "crash", &mu, &crashed)
fmt.Println("\n2) 插件 Y 随后申请同一把锁:")
done := make(chan bool, 1)
go func() { run("Y", "normal", &mu, new(bool)); done <- true }()
select {
case <-done:
fmt.Println("\n✅ Y 正常获得并释放锁 —— 无死锁")
fmt.Println(" → 内核持有锁的所有权,进程死亡由 Wait()/EOF 检测并强制释放")
fmt.Println(" → 不需要 PTHREAD_PROCESS_SHARED|ROBUST也不需要处理 EOWNERDEAD")
fmt.Println(" → 整个架构可做到零 cgo")
case <-time.After(15 * time.Second):
fmt.Println("\n❌ 死锁Y 拿不到锁(说明需要 robust 语义)")
}
_ = crashed
}

View File

@ -0,0 +1,12 @@
//go:build ignore
package main
import ("bufio";"encoding/json";"os")
func main() {
dec := json.NewDecoder(bufio.NewReader(os.Stdin))
w := bufio.NewWriter(os.Stdout); enc := json.NewEncoder(w)
rpc := func(m string) { enc.Encode(map[string]string{"method":m}); w.Flush(); var r map[string]interface{}; dec.Decode(&r) }
rpc("stage.lock")
if os.Args[1] == "crash" { panic("持锁时崩溃") } // 拿着锁死掉
rpc("stage.unlock")
}

View File

@ -0,0 +1,90 @@
//go:build ignore
package main
import (
"encoding/json"
"fmt"
"strings"
"sync"
)
// 完全复刻内核 loader.go case 2 + templates.go go_invoke_stage 的链路
type StageCtx struct {
mu sync.RWMutex
LLMText string
ToolRes []string
}
func (c *StageCtx) Lock() { c.mu.Lock() }
func (c *StageCtx) Unlock() { c.mu.Unlock() }
func (c *StageCtx) RLock() { c.mu.RLock() }
func (c *StageCtx) RUnlock() { c.mu.RUnlock() }
// === 模拟外部插件(副本模型)===
func externalPlugin(tag string, ctxJSON string) string {
// go_invoke_stage: 新建全新对象
sc := &StageCtx{}
var m map[string]interface{}
json.Unmarshal([]byte(ctxJSON), &m)
if v, ok := m["llm_text"].(string); ok { sc.LLMText = v }
// 插件 handlerctx.Lock() 锁的是这个新对象 → 空转
sc.Lock()
sc.LLMText = sc.LLMText + "[" + tag + "]"
sc.Unlock()
out, _ := json.Marshal(map[string]interface{}{"llm_text": sc.LLMText})
return string(out)
}
// === 模拟内核 case 2 handler ===
func kernelStageHandler(sc *StageCtx, tag string) {
sc.RLock()
snap, _ := json.Marshal(map[string]interface{}{"llm_text": sc.LLMText})
sc.RUnlock()
result := externalPlugin(tag, string(snap))
// applyStageResult
var m map[string]interface{}
json.Unmarshal([]byte(result), &m)
sc.Lock()
if v, ok := m["llm_text"].(string); ok { sc.LLMText = v }
sc.Unlock()
}
// === 内置插件:直接改同一对象 ===
func nativePlugin(sc *StageCtx, tag string) {
sc.Lock()
sc.LLMText = sc.LLMText + "[" + tag + "]"
sc.Unlock()
}
func runCase(name string, fn func(*StageCtx, string), tags []string, rounds int) {
lost := 0
for r := 0; r < rounds; r++ {
sc := &StageCtx{LLMText: "BASE"}
var wg sync.WaitGroup
for _, t := range tags {
wg.Add(1)
go func(t string) { defer wg.Done(); fn(sc, t) }(t)
}
wg.Wait()
// 检查是否所有 tag 都在
for _, t := range tags {
if !strings.Contains(sc.LLMText, "["+t+"]") { lost++; break }
}
}
fmt.Printf(" %-28s %d/%d 轮出现修改丢失 (%.1f%%)\n", name, lost, rounds, float64(lost)/float64(rounds)*100)
}
func main() {
tags := []string{"A", "B", "C", "D", "E"}
fmt.Println("5 个插件并发在 StageBeforeToolcall 追加标记,各 2000 轮:")
fmt.Println()
runCase("内置插件(共享同一对象)", nativePlugin, tags, 2000)
runCase("外部插件(快照-副本-写回)", kernelStageHandler, tags, 2000)
fmt.Println()
fmt.Println("→ 副本模型下 read-modify-write 非原子:快照与写回之间的窗口导致覆盖")
}

View File

@ -0,0 +1,101 @@
//go:build ignore
package main
// 精确复刻现网 AfterToolcall 上 sanitizer(Global,改写) + weather(OwnTools,只读) 的并发
import (
"encoding/json"
"fmt"
"strings"
"sync"
)
type ToolResult struct {
Name string `json:"name"`
Plugin string `json:"plugin"`
Result interface{} `json:"result"`
}
type Ctx struct {
mu sync.RWMutex
ToolRes []ToolResult
}
func (c *Ctx) Lock(){c.mu.Lock()}; func (c *Ctx) Unlock(){c.mu.Unlock()}
func (c *Ctx) RLock(){c.mu.RLock()}; func (c *Ctx) RUnlock(){c.mu.RUnlock()}
func cleanText(s string) string {
// 模拟 sanitizer去掉 ANSI/坏字节
return strings.ReplaceAll(s, "\x1b[31m", "")
}
// 内核 case 2 handler外部插件通用路径
func kernelExternal(sc *Ctx, pluginFn func(*Ctx)) {
// 1. 快照
sc.RLock()
snap, _ := json.Marshal(map[string]interface{}{"tool_results": sc.ToolRes})
sc.RUnlock()
// 2. go_invoke_stage: 插件进程内全新对象
local := &Ctx{}
var m map[string]interface{}
json.Unmarshal(snap, &m)
if v, ok := m["tool_results"]; ok {
b, _ := json.Marshal(v)
json.Unmarshal(b, &local.ToolRes)
}
// 3. 插件 handler 跑在副本上
pluginFn(local)
// 4. stageContextWritable: 无条件回传 tool_results
out := map[string]interface{}{}
if len(local.ToolRes) > 0 { out["tool_results"] = local.ToolRes }
rb, _ := json.Marshal(out)
// 5. applyStageResult 写回内核
var rm map[string]interface{}
json.Unmarshal(rb, &rm)
sc.Lock()
if v, ok := rm["tool_results"]; ok {
b, _ := json.Marshal(v)
var trs []ToolResult
if json.Unmarshal(b, &trs) == nil { sc.ToolRes = trs }
}
sc.Unlock()
}
func sanitizerStage(ctx *Ctx) {
ctx.Lock(); defer ctx.Unlock()
for i, tr := range ctx.ToolRes {
if s, ok := tr.Result.(string); ok {
ctx.ToolRes[i].Result = cleanText(s)
}
}
}
func weatherStage(ctx *Ctx) {
ctx.Lock(); defer ctx.Unlock()
// 只读打印不改own_tools scope 已匹配)
_ = len(ctx.ToolRes)
}
func main() {
const rounds = 3000
dirty := "\x1b[31m晴 25°C"
polluted := 0
for r := 0; r < rounds; r++ {
sc := &Ctx{ToolRes: []ToolResult{{Name:"weather_query", Plugin:"weather", Result: dirty}}}
var wg sync.WaitGroup
wg.Add(2)
go func(){ defer wg.Done(); kernelExternal(sc, sanitizerStage) }()
go func(){ defer wg.Done(); kernelExternal(sc, weatherStage) }()
wg.Wait()
if s, ok := sc.ToolRes[0].Result.(string); ok && strings.Contains(s, "\x1b[31m") {
polluted++
}
}
fmt.Printf("现网场景复刻:模型调用 weather_querysanitizer+weather 并发跑 AfterToolcall\n")
fmt.Printf(" %d 轮中 %d 轮清洗结果被覆盖 (%.1f%%)\n", rounds, polluted, float64(polluted)/rounds*100)
if polluted > 0 {
fmt.Printf("\n ⚠️ 确认weather 回传的未清洗快照覆盖了 sanitizer 的清洗结果\n")
fmt.Printf(" → 脏数据ANSI 转义)进入 LLM 上下文\n")
}
}

View File

@ -0,0 +1,62 @@
//go:build ignore
package main
/*
#cgo LDFLAGS: -ldl
#include <dlfcn.h>
#include <stdlib.h>
typedef void (*fn)(void);
static void call(void* f){ ((fn)f)(); }
*/
import "C"
import (
"fmt"
"os"
"os/exec"
"runtime"
"time"
"unsafe"
)
func threads() int { e,_ := os.ReadDir("/proc/self/task"); return len(e) }
func main() {
fmt.Println("=== A. cgo 模型:插件死循环,超时后能回收吗? ===")
p := C.CString("./hang.so"); h := C.dlopen(p, C.RTLD_NOW); C.free(unsafe.Pointer(p))
n := C.CString("hang_forever"); f := C.dlsym(h, n); C.free(unsafe.Pointer(n))
base := threads()
fmt.Printf(" 基线: goroutines=%d threads=%d\n", runtime.NumGoroutine(), base)
for i := 1; i <= 3; i++ {
done := make(chan string, 1)
go func() { C.call(f); done <- "ok" }() // 模拟 executeToolCallInner
select {
case <-done:
case <-time.After(600 * time.Millisecond): // 缩短的"60s 超时"
}
time.Sleep(200 * time.Millisecond)
fmt.Printf(" 第 %d 次超时后: goroutines=%d threads=%d (+%d)\n",
i, runtime.NumGoroutine(), threads(), threads()-base)
}
fmt.Println(" ❌ 每次超时永久泄漏 1 goroutine + 1 OS 线程cgo 调用不可中断)")
fmt.Println("\n=== B. 子进程模型:同样死循环,可强杀 ===")
base2 := threads()
for i := 1; i <= 3; i++ {
cmd := exec.Command("sleep", "3600")
cmd.Start()
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case <-done:
case <-time.After(300 * time.Millisecond):
cmd.Process.Kill() // ← 可强制终止
<-done
}
fmt.Printf(" 第 %d 次超时+Kill 后: goroutines=%d threads=%d (+%d)\n",
i, runtime.NumGoroutine(), threads(), threads()-base2)
}
fmt.Println(" ✅ 零泄漏进程被杀OS 回收全部资源")
}

View File

@ -0,0 +1,43 @@
//go:build ignore
package main
/*
#cgo LDFLAGS: -ldl
#include <dlfcn.h>
#include <stdlib.h>
typedef void (*fn)(void);
static void call(void* f){ ((fn)f)(); }
*/
import "C"
import (
"fmt"
"os"
"runtime"
"time"
"unsafe"
)
func threads() int { e,_ := os.ReadDir("/proc/self/task"); return len(e) }
func main() {
p := C.CString("./hang.so"); h := C.dlopen(p, C.RTLD_NOW); C.free(unsafe.Pointer(p))
n := C.CString("hang_forever"); f := C.dlsym(h, n); C.free(unsafe.Pointer(n))
base := threads()
fmt.Printf("基线 threads=%d goroutines=%d\n\n", base, runtime.NumGoroutine())
for i := 1; i <= 20; i++ {
done := make(chan string, 1)
go func() { C.call(f); done <- "ok" }()
select {
case <-done:
case <-time.After(120 * time.Millisecond):
}
if i%5 == 0 {
fmt.Printf(" %2d 次卡死调用后: goroutines=%2d threads=%2d (+%d)\n",
i, runtime.NumGoroutine(), threads(), threads()-base)
}
}
fmt.Printf("\n结论: 20 次超时 → 泄漏 %d goroutine, %d OS 线程\n",
runtime.NumGoroutine()-1, threads()-base)
fmt.Println("每个卡在 cgo 里的 goroutine 独占一个 MOS 线程),无法被抢占或回收")
}

View File

@ -0,0 +1,2 @@
#include <unistd.h>
void hang_forever(void) { while(1) sleep(1); }

View File

@ -0,0 +1,137 @@
# 实验 19迁移验证工具Part 6.3
外部插件从 C ABI 动态库迁移到子进程后的批量重编与开销实测工具。
与 01~18 的性质不同:那些是**决策前**的可行性验证,这两个是**迁移执行期**
反复使用的操作脚本。
## rebuild-plugins.sh
批量把 `example/` 下的插件重编为子进程模式(`plugin.bin`)。
```bash
PLUGINDEV=/tmp/plugindev ./rebuild-plugins.sh weather sanitizer qq
```
关键性质:**不修改任何插件源码**。`plg.json``entry` 仍写着 `"plugin.so"`
也无妨——工具链已不看这个字段Part 6.1)。
两个实现细节值得记:
- **成功判定看产物而非退出码**。plugindev 对部分错误只 `fmt.Printf`
`os.Exit`,单看 `$?` 会把失败当成功。
- 构建前清 `build/`+`dist/`。残留的 `.so` 不影响构建,但会让人误以为
还在用旧通道。
已知环境依赖:`rss` 插件需要 `github.com/mmcdole/gofeed`
`proxy.golang.org` 不通时用 `GOPROXY=https://goproxy.cn,direct`
## measure-plugin-overhead.sh
实测 homed + 插件子进程的常驻开销。
```bash
./measure-plugin-overhead.sh $(pgrep -f 'homed -data' | head -1)
```
### 一个统计口径的坑
第一版混用了两个来源RSS 读 `/proc/pid/status``VmRSS`
PSS 读 `smaps_rollup``Pss`。结果输出 `PSS=87.9MB > RSS=69.1MB`——
物理上不可能。
原因是两者对**共享内存段**的计入方式不同:`smaps_rollup``Rss`
`Pss_Shmem`(共享段的按比例份额),`VmRSS` 不含。现已统一从
`smaps_rollup` 读,保证 PSS ≤ RSS。
### 实测结果2026-09-0215 个真实插件)
```
15 个插件进程 RSS=88.0 MB PSS=87.9 MB 线程=82
均摊 5.87 MB 5.86 MB 5.5 线程
homed 本体 RSS=182 MB 线程=15
```
**与实验 5 基线17 进程 RSS=29.1MB / PSS=12.9MB / 线程=84的偏差解释**
实验 5 用的是 2.68MB 的最小插件,真实插件 3.1~14.8MBbrowser 依赖最多)。
RSS 随二进制体积线性增长,故绝对数字不可比。可比的是结构性指标:
| 指标 | 基线 | 实测 | 判断 |
|---|---|---|---|
| 均摊线程 | 4.9 | 5.5 | 同量级,无线程膨胀 |
| PSS/RSS | 44% | 99.9% | **明显差于基线** |
第二项是真实发现:基线里 PSS 远低于 RSS说明 Go runtime 只读代码页在
进程间共享。实测几乎不共享,因为 15 个插件是 15 个**不同**的二进制,
没有共同的物理页可映射。
这是「每插件独立二进制」的固有代价,不是缺陷,但意味着实际内存开销
高于评估文档§4.3)的乐观估计。若日后需要压这一项,方向是让插件共享
一个 launcher 二进制 + 各自的业务 plugin而非各自静态链接整个 runtime。
## 冒烟测试
自动化部分在 `internal/plugins/real_plugin_smoke_test.go`4 项):
- `ToolInvokeRoundTrip`:工具真实调用往返(不只是注册)
- `StageRewriteTakesEffect`sanitizer 改写型 stage 在真实内核装配下生效
- `MultiPluginShareOneSegment`:多插件共享一段,只读插件不覆盖改写结果
- `CrashDoesNotKillKernel`SIGKILL 插件进程homed 存活
这些测试用**真实 example 产物**而非 testdata 假插件,且 manifest 刻意写
`"entry":"plugin.so"`——验证「业务代码零改动」这一承诺在完整内核装配下成立。
未重编时 skip 而非 failCI 不强制先跑重编脚本。
## 压测与延迟Part 6.6 验收)
基准与压测在代码里而非独立脚本:
`internal/plugin/proc/bench_test.go` + `streaming_test.go`
```bash
go test -run '^$' -bench . ./internal/plugin/proc/
go test -run 'TestStreaming_' -v ./internal/plugin/proc/
```
### 实测2026-09-02AMD Ryzen 7 7840HS
| 项目 | 实测 | 基线 | 判断 |
|---|---|---|---|
| 工具调用 RPC 往返 | 24.1 µs | 实验 11: 19.6 µs | 同量级 |
| 锁仲裁(内核侧) | 0.76 µs | — | 见下注 |
| 事件环写入 | 95 ns | — | 亚微秒 |
| 事件环并发写入 | 83 ns | — | 无锁竞争恶化 |
| 完整 stage 往返 | 132 µs | — | 含 3 次进程间往返 |
| 共享段编解码 | 3.7 µs | — | 占 stage 的 2.8% |
**锁仲裁 0.76µs 不可与实验 3 的 19.40µs 对照**——两者测的不是同一个东西:
实验 3 测插件经 RPC 请求锁的完整跨进程往返,本基准只测内核侧
`lockRegistry.acquire/release`。真实成本仍在 20µs 量级(那部分是 RPC 往返)。
基准原名 `BenchmarkStageLockRoundTrip` 有误导性,已改为
`BenchmarkStageLockArbitration`
**stage 往返 132µs 的成本构成**:共享段编解码只占 3.7µs2.8%
其余是**一次 stage 要走 3 次进程间往返**——`stage.invoke` 加上插件侧反向的
`stage.lock` / `stage.unlock`。相对 LLM 往返 2-8 秒可忽略;若日后要优化,
方向是把 lock/unlock 合入 `stage.invoke` 的请求/应答,省掉两次往返。
### 流式压测§4.3 标记「风险高」的那一项)
原文的担忧:「`Bus.Publish` 路径禁用任何锁/阻塞——流式输出逐 token 发布,
任何等待都会卡顿」。
```
5000 次 Publish + 每条睡 20µs 的慢消费者
实测 2.29ms,均摊 457 ns/token
同步语义理论下限 100ms5000 × 20µs
订阅者 1 个1.547ms515 ns/次)
订阅者 8 个1.518ms506 ns/次) ← 几乎不变,无线性恶化
环溢出(无消费者写 30000 次cap=8192均摊 35 ns/次 ← 仍 O(1)
```
2.29ms 与实验 4 的数字完全一致(那次也是 2.29ms / 0.46µs per token——
post-and-forget 在实现中成立。
最后一项的意义:消费者完全停摆时写端覆盖最旧 slot这条路径仍是 O(1)
故「消费者卡住」不会连带拖慢内核主循环。

View File

@ -0,0 +1,82 @@
#!/usr/bin/env bash
# 子进程插件常驻开销实测Part 6.3 验收项)。
#
# 对照基线docs/zh/experiments/plugin-arch 实验 5 实测 17 子进程
# PSS=12.9MB / RSS=29.1MB / 线程=84原文档估计 50-70MB 偏高)。
#
# 用法:./measure-plugin-overhead.sh <homed-pid>
set -uo pipefail
pid=${1:-}
if [ -z "$pid" ]; then
echo "用法: $0 <homed-pid>" >&2
exit 1
fi
if [ ! -d "/proc/$pid" ]; then
echo "进程 $pid 不存在" >&2
exit 1
fi
# homed 本体
homed_rss=$(awk '/^VmRSS:/ {print $2}' "/proc/$pid/status")
homed_thr=$(awk '/^Threads:/ {print $2}' "/proc/$pid/status")
echo "=== homed 本体 ==="
printf "RSS=%s kB 线程=%s\n" "$homed_rss" "$homed_thr"
# 插件子进程homed 的直接子进程中执行 plugin.bin 的
echo
echo "=== 插件子进程 ==="
total_rss=0
total_pss=0
total_thr=0
count=0
for child in $(pgrep -P "$pid" 2>/dev/null); do
exe=$(readlink "/proc/$child/exe" 2>/dev/null || true)
case "$exe" in
*plugin.bin*) ;;
*) continue ;;
esac
thr=$(awk '/^Threads:/ {print $2}' "/proc/$child/status" 2>/dev/null || echo 0)
# RSS 与 PSS 统一从 smaps_rollup 读,保证口径一致。
# 混用 status 的 VmRSS 与 smaps 的 Pss 会得出 PSS > RSS 的荒谬结果——
# 两者对共享内存段Pss_Shmem的计入方式不同。
rss=$(awk '/^Rss:/ {print $2}' "/proc/$child/smaps_rollup" 2>/dev/null || echo 0)
pss=$(awk '/^Pss:/ {print $2}' "/proc/$child/smaps_rollup" 2>/dev/null || echo 0)
if [ -z "$rss" ] || [ "$rss" = "0" ]; then
rss=$(awk '/^VmRSS:/ {print $2}' "/proc/$child/status" 2>/dev/null || echo 0)
fi
binsz=$(stat -c%s "$(readlink "/proc/$child/exe" 2>/dev/null)" 2>/dev/null || echo 0)
name=$(basename "$(readlink "/proc/$child/cwd" 2>/dev/null || echo unknown)")
printf " %-16s pid=%-8s RSS=%-8s PSS=%-8s 线程=%-3s 二进制=%s MB\n" \
"$name" "$child" "$rss" "$pss" "$thr" \
"$(awk -v b="$binsz" 'BEGIN{printf "%.1f", b/1048576}')"
total_rss=$((total_rss + rss))
total_pss=$((total_pss + pss))
total_thr=$((total_thr + thr))
count=$((count + 1))
done
echo
echo "=== 合计($count 个插件进程)==="
awk -v rss="$total_rss" -v pss="$total_pss" -v thr="$total_thr" -v n="$count" '
BEGIN {
printf "RSS=%d kB (%.1f MB)\n", rss, rss/1024
printf "PSS=%d kB (%.1f MB)\n", pss, pss/1024
printf "线程=%d\n", thr
if (n > 0) printf "均摊 RSS=%.2f MB PSS=%.2f MB 线程=%.1f\n", rss/1024/n, pss/1024/n, thr/n
}'
echo
echo "注RSS/PSS 均取自 smaps_rollup口径一致PSS ≤ RSS。"
echo "PSS 低于 RSS 的部分即 Go runtime 只读代码页在进程间的共享收益。"
echo
echo "对照实验 5 基线17 进程 RSS=29.1MB PSS=12.9MB 线程=84"
echo
echo "⚠️ 该基线用的是 2.68MB 的最小插件;真实插件 3.3~15.2MBbrowser 依赖最多)。"
echo " RSS 随二进制体积线性增长,故不可直接与基线数字比较——"
echo " 要比的是「均摊线程数」与「PSS/RSS 比值(共享收益)」这两个结构性指标。"

View File

@ -0,0 +1,56 @@
#!/usr/bin/env bash
# 批量重编外部插件为子进程模式Part 6.3)。
#
# 用法:./rebuild-plugins.sh <插件名>...
#
# 关键性质:**不修改任何插件源码**。每个插件只需用新版 plugindev 重编,
# plg.json 的 entry 仍写着 "plugin.so" 也无妨——工具链已不看这个字段。
set -uo pipefail
PLUGINDEV=${PLUGINDEV:-/tmp/plugindev}
EXAMPLE_DIR=${EXAMPLE_DIR:-"$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../.." && pwd)/third_party/homeagent-sdk/example"}
export GOCACHE=${GOCACHE:-/tmp/gocache}
export GOPATH=${GOPATH:-/tmp/gopath}
if [ ! -x "$PLUGINDEV" ]; then
echo "plugindev 不存在或不可执行: $PLUGINDEV" >&2
exit 1
fi
ok=0
fail=0
failed_names=""
for name in "$@"; do
dir="$EXAMPLE_DIR/$name"
if [ ! -d "$dir" ]; then
echo "$name: 目录不存在"
fail=$((fail + 1))
failed_names="$failed_names $name"
continue
fi
# 清理旧 C ABI 产物:同目录残留 .so 不影响构建,但会让人误以为还在用旧通道
rm -rf "$dir/build" "$dir/dist"
out=$(cd "$dir" && "$PLUGINDEV" build 2>&1)
rc=$?
# 判定成功的依据是产物存在而非退出码plugindev 对部分错误只打印不退出
if [ $rc -eq 0 ] && ls "$dir"/build/plugin.bin* >/dev/null 2>&1; then
n=$(ls "$dir"/build/plugin.bin* 2>/dev/null | wc -l)
hmap=$(ls "$dir"/dist/*.hmap 2>/dev/null | head -1)
printf "✓ %-14s %s 个平台产物 %s\n" "$name" "$n" "$(basename "${hmap:- hmap}")"
ok=$((ok + 1))
else
printf "✗ %-14s 构建失败\n" "$name"
echo "$out" | tail -6 | sed 's/^/ /'
fail=$((fail + 1))
failed_names="$failed_names $name"
fi
done
echo
echo "成功 $ok / 失败 $fail"
[ -n "$failed_names" ] && echo "失败:$failed_names"
exit $([ $fail -eq 0 ] && echo 0 || echo 1)

View File

@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""生产切换:经 pluginmgr 正规通道安装 17 个 hmapPart 6.5)。
与手工拷贝方案的区别 —— 这里复用内核自己的安装逻辑:
validatePackage 校验 manifest + 平台二进制齐全
StopAndUnload 停旧实例但**保留配置表**
os.Rename 备份 解包失败自动回滚到旧版本
platformBinary() 按 runtime 挑当前平台那份,重命名为 plugin.bin
chmod 0755 补执行位
手工拷贝会重新实现这一套,且必然实现得更差(第一版就漏了 platforms 字段
与配置保留语义)。
用法:
switch-production.py 演练
switch-production.py --apply 实际安装
"""
import json
import os
import sys
import urllib.error
import urllib.request
PROD_PLUGINS = "/home/newqqagent/plugins"
SDK_EXAMPLE = "/home/program/TrueAgent/third_party/homeagent-sdk/example"
PLUGINMGR = "http://127.0.0.1:9876/plugins"
def find_hmap(name):
"""找插件的 hmap 包。
bundle:true -> <snake>_bundle.hmap含多平台二进制
bundle:false -> <snake>_<goos>_<goarch>.hmapqq 是这种)
"""
dist = os.path.join(SDK_EXAMPLE, name, "dist")
if not os.path.isdir(dist):
return None
cands = [f for f in os.listdir(dist) if f.endswith(".hmap")]
if not cands:
return None
for c in cands:
if c.endswith("_bundle.hmap"):
return os.path.join(dist, c)
return os.path.join(dist, sorted(cands)[0])
def install(path):
"""POST 到 pluginmgr。overwrite=true 走原地更新分支,保留配置表。"""
body = json.dumps({"path": path, "overwrite": True}).encode()
req = urllib.request.Request(
PLUGINMGR, data=body,
headers={"Content-Type": "application/json"},
method="POST")
try:
with urllib.request.urlopen(req, timeout=180) as resp:
return json.loads(resp.read().decode()), None
except urllib.error.HTTPError as e:
return None, "HTTP %d: %s" % (e.code, e.read().decode()[:300])
except Exception as e:
return None, str(e)
def main():
apply = "--apply" in sys.argv
targets = sorted(
d for d in os.listdir(PROD_PLUGINS)
if os.path.isfile(os.path.join(PROD_PLUGINS, d, "plugin.so"))
or os.path.isfile(os.path.join(PROD_PLUGINS, d, "plugin.bin"))
)
print("生产外部插件: %d" % len(targets))
# 先全部校验,任一缺包就整批中止。
# 理由:新 homed 不认 .so「一半装了一半没装」的中间态最难排查。
plan = []
missing = []
for name in targets:
h = find_hmap(name)
if h is None:
missing.append(name)
else:
plan.append((name, h))
if missing:
print("\n✗ 中止:以下插件缺 hmap 包:")
for m in missing:
print(" " + m)
print("\n先跑 rebuild-plugins.sh 重编。")
return 1
print("✓ 全部 %d 个 hmap 就位\n" % len(plan))
for name, h in plan:
print(" %-16s %-44s %6d KB" % (
name, os.path.basename(h), os.path.getsize(h) // 1024))
if not apply:
print("\n[演练] 加 --apply 才实际安装")
return 0
print("\n经 pluginmgr 安装overwrite=true保留配置...")
ok = 0
failed = []
for name, h in plan:
result, err = install(h)
if err:
print("%-16s %s" % (name, err))
failed.append(name)
continue
if "error" in result:
print("%-16s %s: %s" % (
name, result["error"], result.get("details", "")))
failed.append(name)
continue
print("%-16s %-12s v%s -> v%s config_kept=%s" % (
name,
result.get("action", "?"),
result.get("previous_version", "?"),
result.get("version", "?"),
result.get("config_kept", False)))
ok += 1
print("\n成功 %d / 失败 %d" % (ok, len(failed)))
if failed:
print("失败: " + " ".join(failed))
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,111 @@
# 插件架构评估实验
[`../../架构迁移评估.md`](../../架构迁移评估.md) 中所有数字的来源。
**18 项实验,一键复跑**,用于复核结论或在改动后验证回归。
```bash
./run.sh # 跑全部(约 3-5 分钟)
./run.sh 12 13 # 只跑指定实验
./run.sh 1 1c # dlclose/NODELETE 组
```
依赖:`go >= 1.21``gcc`、Linux用到 `eventfd`/`memfd_create`/`dlopen`)。
脚本在 `mktemp -d` 里构建,**不污染主仓 `go.mod`**;实验源码均带 `//go:build ignore`
拉取 `golang.org/x/sys` 需要网络(实验 1/2/4/8/10。本机走 clash
```bash
export HTTPS_PROXY=http://127.0.0.1:7890 HTTP_PROXY=http://127.0.0.1:7890
```
## 目录
| 目录 | 主题 | 对应章节 |
|---|---|---|
| `01-dlclose-nodelete/` | `dlclose``DF_1_NODELETE` 是 no-op | 1.1 / 1.2 |
| `02-feasibility/` | 新架构可行性 11 项 | 第七章 |
| `03-lost-update/` | 副本模型的 lost update | 8.4 / 8.6 |
| `04-cgo-uninterruptible/` | cgo 调用不可中断 | 9.3 |
## 实验清单与最近一次实测结果
复跑于 2026-08-31go1.25.12 linux/amd64192.168.2.6012 核)。
### 01 组dlclose / NODELETE
| # | 实验 | 结论 |
|---|---|---|
| 1a | Go 宿主经纯 C shim 加载/卸载第三层 `.so` | 纯 C 目标可卸载Go c-shared 目标仍不可 |
| 1b | `/proc/self/maps` 段数验证 | 纯 C: 5→**0**真卸载Go c-shared: 5→**5** |
| 1c | 版本化路径 dlopen | handle 不同,`ver=v2` 生效(方案可行但泄漏,已否决) |
**关键**`DF_1_NODELETE` 属于**被卸载对象自身**的 ELF 属性,
与谁调用 `dlopen` 无关——套任何层数的 C 中间件都绕不过去。
### 02 组:新架构可行性
| # | 实验 | 最近结果 |
|---|---|---|
| 1 | eventfd 是否走 Go netpoller | 200 goroutine 阻塞 → 线程 **+0~1** ✅ |
| 2 | 跨进程 eventfd + 偏移解引用 | 父子 mmap 基址不同偏移仍正确post **10.9 µs** |
| 3 | 锁仲裁 RPC 往返成本 | **19.4 µs/次**20000 次) |
| 4 | post-and-forget vs 同步 Publish | 5.07s → 2.29ms**2218x** |
| 5 | 17 子进程常驻开销 | **29.1MB RSS / 12.9MB PSS**84 线程 |
| 6 | 子进程崩溃隔离 | 退出码 **2**EOF **2.5ms** 感知,宿主存活 |
| 7 | 子进程热重载 | 同路径替换二进制 → v1→v2 立即生效 |
| 8 | **跨进程并发改写 StageContext** | 5 进程 × 300 轮,**零丢失零撕裂** |
| 9 | 持锁进程崩溃自愈 | 无死锁,**无需 robust mutex** |
| 10 | 二进制零拷贝 | 100KB/1MB/5MB → **14-22x**,体积 100% |
| 11 | 工具调用 RPC 延迟 | p50 **19.6 µs**,占 LLM 往返 0.00065% |
### 03 组:副本模型缺陷
| # | 实验 | 最近结果 |
|---|---|---|
| 12 | 副本模型 lost update 率 | 内置 **0%** vs 外部 **35.8~36.8%** |
| 13 | 现网 sanitizer+weather 冲突 | **1.6~4.3%** 清洗结果被覆盖 |
**实验 12 的对照设计是重点**:两组用**完全相同的并发扇出**
`stages.go:124``go func` + `wg.Wait()`),唯一差异是
「共享同一 `*StageContext`」vs「快照-副本-写回」。
内置组 0% 证明**并发扇出这个原始设计是正确的**
副本组 36% 证明**跨 C ABI 边界后锁语义失效**才是缺陷所在。
不要据此得出"应该取消并发"的结论。
⚠️ **13 的比率随机器负载波动**(观测区间 1.6%~4.3%)——它取决于两个插件
handler 的实际执行耗时比。文档正文引用 1.6% 是首次测量值,
**应理解为「量级在百分之几」而非精确常数**
### 04 组cgo 不可中断
| # | 实验 | 最近结果 |
|---|---|---|
| 14a | cgo 死循环 vs 子进程 Kill | cgo 泄漏;子进程 **零泄漏** |
| 14b | 泄漏增长曲线20 次) | 泄漏 **20 goroutine / 18 OS 线程**,线性 |
## 复跑时的注意事项
**结果会有波动,以下属正常**
- 实验 12/13 的丢失率随调度波动12 稳定在 35~37%13 在 1.6~4.3%
- 实验 1 的线程增长为 0 或 1取决于 netpoller 线程是否已存在)
- 实验 10 的加速比 14~22x受 CPU 缓存状态影响)
- 实验 5 的 PSS 受同机其他 Go 进程影响(共享页计算)
**结果不应变的**(若变了说明环境或结论有问题):
- 实验 1b 中纯 C `.so` 的段数必须归 **0**Go c-shared 必须**不归零**
- 实验 8 的「总字符数 == 最终长度」必须成立(零丢失)
- 实验 9 必须无死锁
- 实验 12 的内置模型必须 **0%**
- 实验 14b 的泄漏必须**线性增长**
## 已知限制
- 实验 8 的 arena 未实现压实64KB 用尽即停止写入(写入次数 < 5×300 属预期
见评估文档 3.3
- 实验 12/13 **链路复刻**而非直接调用生产代码
证明的是副本模型这一机制存在缺陷不能替代对 `sanitizer`/`weather`
的真实行为回归测试
- 实验 5 的插件是最小 stdio loop2.68MB真实插件 qq 7.5MB开销更高
- Windows 环境9.2 Windows DLL 缺陷**未经实测**仅代码阅读

View File

@ -0,0 +1,127 @@
#!/usr/bin/env bash
# 插件架构评估实验 —— 一键复跑
# 用法: ./run.sh [实验编号...] 例: ./run.sh 12 13 留空跑全部
# 依赖: go >= 1.21, gcc, Linux (eventfd/memfd/dlopen)
set -uo pipefail
cd "$(dirname "$0")"
ROOT=$(pwd)
PASS=0; FAIL=0
need() { command -v "$1" >/dev/null || { echo "缺少依赖: $1"; exit 1; }; }
need go; need gcc
# 统一的临时 module 环境(避免污染主仓 go.mod
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
banner() { echo; echo "════════ $* ════════"; }
# x/sys 只有 exp1/2/4/8/10 需要
prep_xsys() {
cat > "$1/go.mod" <<EOF
module exp
go 1.21
require golang.org/x/sys v0.20.0
EOF
(cd "$1" && GOFLAGS=-mod=mod go get golang.org/x/sys@v0.20.0 >/dev/null 2>&1)
}
prep_plain() { printf 'module exp\ngo 1.21\n' > "$1/go.mod"; }
run_go() { # <目录> <说明>
if (cd "$1" && go run . 2>&1); then PASS=$((PASS+1)); else echo " ❌ 失败: $2"; FAIL=$((FAIL+1)); fi
}
SEL="${*:-all}"
sel() { [ "$SEL" = "all" ] && return 0; case " $SEL " in *" $1 "*) return 0;; esac; return 1; }
# ── 01: dlclose / NODELETE ────────────────────────────────
if sel 1; then
banner "实验 1 组: dlclose 对 DF_1_NODELETE 是 no-op"
W=$WORK/e01; mkdir -p $W; cp 01-dlclose-nodelete/*.c $W/
gcc -shared -fPIC -o $W/probe_v1.so $W/probe_v1.c
gcc -shared -fPIC -o $W/probe_v2.so $W/probe_v2.c
gcc -shared -fPIC -o $W/shim.so $W/shim.c
cp $W/probe_v1.so $W/probe.so
for e in exp01a exp01b; do
mkdir -p $W/$e; cp 01-dlclose-nodelete/$e/main.go $W/$e/
sed -i '/^\/\/go:build ignore$/d' $W/$e/main.go; prep_plain $W/$e
(cd $W/$e && go build -o ../$e.bin . 2>&1 | head -3)
done
echo "--- 01a: Go 宿主经 C shim 加载/卸载纯 C so ---"
(cd $W && ./exp01a.bin) && PASS=$((PASS+1)) || FAIL=$((FAIL+1))
echo "--- 01b: /proc/self/maps 段数验证(纯 C 归零Go c-shared 不归零)---"
(cd $W && ./exp01b.bin) && PASS=$((PASS+1)) || FAIL=$((FAIL+1))
fi
# ── 01c: 版本化路径(需要两个真 Go c-shared────────────────
if sel 1c; then
banner "实验 1c: 版本化路径 dlopen 可加载新代码"
W=$WORK/e01c; mkdir -p $W/{v1,v2,host}
for V in v1 v2; do
cat > $W/$V/main.go <<EOF
package main
import "C"
//export lib_version
func lib_version() *C.char { return C.CString("$V-CODE") }
func main() {}
EOF
printf 'module gl%s\ngo 1.21\n' $V > $W/$V/go.mod
(cd $W/$V && go build -buildmode=c-shared -o ../gl$V.so . 2>&1|head -3)
done
cp 01-dlclose-nodelete/exp01c/main.go $W/host/
sed -i '/^\/\/go:build ignore$/d' $W/host/main.go; prep_plain $W/host
(cd $W/host && go build -o ../h.bin .) && (cd $W && ./h.bin) && PASS=$((PASS+1)) || FAIL=$((FAIL+1))
fi
# ── 02: 可行性 1-11 ───────────────────────────────────────
declare -A XSYS=([1]=1 [2]=1 [4]=1 [8]=1 [10]=1)
for n in 1 2 3 4 5 6 7 8 9 10 11; do
sel $n || continue
banner "实验 $n"
W=$WORK/f$n; mkdir -p $W
case $n in
1) cp 02-feasibility/exp1_eventfd.go $W/main.go ;;
2) cp 02-feasibility/exp2_parent.go $W/main.go; cp 02-feasibility/exp2_child.go $W/ ;;
3) cp 02-feasibility/exp3_parent.go $W/main.go; cp 02-feasibility/exp3_child.go $W/ ;;
4) cp 02-feasibility/exp4.go $W/main.go ;;
5) cp 02-feasibility/exp5b.go $W/main.go; cp 02-feasibility/exp5_plugin.go $W/ ;;
6) cp 02-feasibility/exp6.go $W/main.go; cp 02-feasibility/exp6_crash.go $W/ ;;
7) cp 02-feasibility/exp7.go $W/main.go ;;
8) cp 02-feasibility/exp8.go $W/main.go; cp 02-feasibility/exp8_worker.go $W/ ;;
9) cp 02-feasibility/exp9.go $W/main.go; cp 02-feasibility/exp9_worker.go $W/ ;;
10) cp 02-feasibility/exp10.go $W/main.go ;;
11) cp 02-feasibility/exp11.go $W/main.go; cp 02-feasibility/exp11_plug.go $W/ ;;
esac
# 去掉 main.go 的 build ignore它是入口
sed -i '/^\/\/go:build ignore$/d' $W/main.go
if [ "${XSYS[$n]:-}" = "1" ]; then prep_xsys $W; else prep_plain $W; fi
# 需要预编译的辅助二进制
case $n in
5) (cd $W && go build -o plugbin exp5_plugin.go 2>&1|head -3) ;;
6) (cd $W && go build -o crashbin exp6_crash.go 2>&1|head -3) ;;
11) (cd $W && go build -o plug11 exp11_plug.go 2>&1|head -3) ;;
esac
run_go $W "实验 $n"
done
# ── 03: lost update ───────────────────────────────────────
for e in 12 13; do
sel $e || continue
banner "实验 $e: 副本模型 lost update"
W=$WORK/l$e; mkdir -p $W
cp 03-lost-update/exp$e/main.go $W/; sed -i '/^\/\/go:build ignore$/d' $W/main.go
prep_plain $W; run_go $W "实验 $e"
done
# ── 04: cgo 不可中断 ──────────────────────────────────────
for e in 14a 14b; do
sel 14 || sel $e || continue
banner "实验 $e: cgo 调用不可中断"
W=$WORK/c$e; mkdir -p $W
cp 04-cgo-uninterruptible/hang.c $W/
gcc -shared -fPIC -o $W/hang.so $W/hang.c
cp 04-cgo-uninterruptible/exp$e/main.go $W/; sed -i '/^\/\/go:build ignore$/d' $W/main.go
prep_plain $W; run_go $W "实验 $e"
done
banner "汇总: 通过 $PASS, 失败 $FAIL"
[ $FAIL -eq 0 ]

View File

@ -0,0 +1,317 @@
# 外部插件接口不变矩阵(多进程化整改基线)
> 状态:**完成 v2**2026-09-03——迁移已落地并上生产内核 v1.0.0。
> 目的:钉死「暴露给外部插件的接口不变」这一约束的**合同面**——迁移前、迁移后外部插件看到/调用的 SDK 接口完全一致;
> 所有改造落在**核心homed 侧)+ 工具链plugindev**,外部插件业务代码零改动,只需用新 plugindev 重编。
>
> **结果(已验证)**`git diff third_party/homeagent-sdk/sdk/` 全程为空17 个 `example/*/plugin.go` 逐字节未改
> `git status example/` 无输出);生产 17 插件全部经子进程通道运行。
>
> 维护规则:每次改动公开 SDK 接口面 `third_party/homeagent-sdk/sdk/` 或模板 `tools/plugindev/templates/` 后,
> 必须同步更新本矩阵。
>
> 权威编号plan.md 第 11 节11.1~11.9)。本文档只做接口面盘点,不做实现。
---
## 一、迁移的形状(一句话)
```
今天: 外部插件 = example/*/plugin.go纯 Go ──plugindev c-shared──> plugin.so
homed ──dlopen──> plugin.soC ABI bridge51 个整数 method id
之后: 外部插件 = example/*/plugin.go纯 Go一行不改 ──plugindev go build──> plugin.bin
homed ──spawn──> plugin.binstdio JSON-RPC + shm + eventfd
```
**为什么接口可以不变**(已代码核实):
| 层 | 含 cgo | 迁移后动作 |
|---|---|---|
| 公开 SDK `third_party/homeagent-sdk/sdk/*.go` | ❌ 纯 Go | **不动**(接口面 = 合同) |
| 外部插件业务代码 `example/*/plugin.go` | ❌ 纯 Go只 import 公开 SDK | **不动**(只重编) |
| bridge 模板 `tools/plugindev/templates.go``tmplLinuxBridge`/`tmplBridge` | ✅ cgo | **删除/替换**为 `tmplProcMain` |
| `plugindev` 构建命令 | c-shared | 改普通 `go build` |
| homed `internal/plugin/cabi/`1096 行) | cgo | 删(已归入 plan 迁移收尾 5.2 |
| homed `internal/plugin/registry.go` 加载分派 | — | 改:按 `entry` 分派 `.so`/`.bin` |
---
## 二、合同面 A公开 SDK 类型与接口(迁移前后必完全一致)
文件:`third_party/homeagent-sdk/sdk/{plugin.go,memory.go,knowledge.go,llm.go,settings.go}`
### A1. 插件入口契约Plugin 接口)
```go
type Plugin interface {
Name() string
Start(sdk *PluginSDK) error
Stop() error
}
// 外部插件实现 NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error)
```
### A2. 插件可注册的 5 类组件PluginSDK 方法)
| PluginSDK 方法 | 签名 | 外部插件使用量example 实测) |
|---|---|---|
| `RegisterTool` | `(name string, def ToolDef, handler ToolHandler) error` | **86** |
| `RegisterStage` | `(stage Stage, handler StageHandler, scope ...StageScope)` | 6 |
| `RegisterOutputChannel` | `(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error` | 4 |
| `RegisterInputChannel` | `(name string, def ChannelDef) error` | 2 |
| `RegisterPluginAPI` | `(name string) error` | 0定义存在可用 |
### A3. 插件可调用的能力访问器PluginSDK 方法)
| 访问器 | 返回 | 外部插件使用量 |
|---|---|---|
| `Settings()` | `SettingsAPI` | **17 插件全部使用**Get/Set/List/GetCore/SetCore/ListCore/DataDir/GetPlugin/SetPlugin/ListPlugin/RegisterDef/Defs/Dump/Plugins |
| `Memory()` | `MemoryAPI`Recall/Commit/Introspect/MergeEntities/Purge | 低controllable |
| `DocMemory()` | `DocMemoryAPI`Query/Insert/Remove/Stats | 低 |
| `TextMemory()` | `TextMemoryAPI`Append | 0 当前 |
| `Knowledge()` | `KnowledgeAPI`Search/Add/List | 2 |
| `LLM()` | `LLMAPI`ListSources/SetSource/CurrentSource | 0 当前 |
| `Social()` | `SocialAPI`**只读**GetPerson/GetTrait/GetRelations/GetNetwork/ListPersons | 0 当前 |
| `Events()` | `EventSubscriber`Subscribe | 0 当前(**C ABI 空实现**,迁移后可获得) |
| `PluginMgr()` | `PluginMgrAPI`ReloadOne/ListLoadedPlugins/IsPluginDisabled | 0 当前 |
| `AutoRestart()` | `bool` | 配套 SetAutoRestart 用 |
### A4. 生命周期 / 工具注入PluginSDK 方法)
| 方法 | 签名 | 备注 |
|---|---|---|
| `SetAutoRestart` / `AutoRestart` | `(bool)` / `() bool` | example 使用 16 次 |
| `InjectText` | `(source, channel, text string)` | → C ABI case 5 |
| `InjectInterruptText` | `(source, channel, text string)` | example 使用 6 次 → case 6 |
| `InjectTextNoMemory` | `(source, channel, text string)` | → case 7 |
| `InjectInputSync` | `(source, channel, text string) string` | → case 47qq 闭环) |
| `SetToolBlocks` | `(blocks []ContentBlock)` | **当前空实现**C ABI 无对应),迁移后经 arena 二进制注入可实现 |
| `RegisterStopHandler` / `RunStopHandlers` | `(func())` / `()` | 已有qq 等 1 次) |
| `RegisterOnRemoveHandler` / `RunOnRemoveHandlers` | `(func())` / `()` | example 使用 3 次 |
| `Set*`SetIOInjector/SetMemoryAPI/.../SetPluginMgrAPI | — | 供 bridge/核心启动时接线,插件不直接调 |
### A5. 核心数据类型(迁移前后结构体字段/JSON tag 不变)
| 类型 | 关键字段 | 备注 |
|---|---|---|
| `StageContext` | 16 字段RawMessage/UserID/GroupID/ContextMsgs/LLMText/ReasoningContent/TokenUsage/ToolCalls/ToolResults/FinalText/Response/Phase/Memory/NoMemory/Extra/Errors + Lock/RLock/Unlock/RUnlock/IsResponded | **注意**:外部插件经 C ABI 只能看到 10 个字段(见 C3迁移到共享内存后可看到全部 16 个 |
| `ToolDef` | Name/Plugin/Description/Parameters/NoMemory/Cleaner(func) | `Cleaner` 是函数,**无法过 C ABI**(迁移后经 RPC/进程内保留) |
| `ChannelDef` | NoMemory/Cleaner(func) | 同上 |
| `ToolCall` / `ToolResult` / `MemItem` | ID/Name/Plugin/ArgumentsCallID/Name/Plugin/Success/ResultRole/Content/Score | 全部纯 JSON 可序列化 |
| `ContentBlock` / `ImageURL` / `AudioURL` | Type/Text/ImageURL/AudioURLURL/DetailURL | 全部可偏移化(迁移评估 3.3 已核实) |
| `Event` / `EventHandler` / `EventSubscriber` | Type/Source/Payload/Timestamp | 迁移后才对外部插件真正可用 |
| `Triple` / `Entity` / `Relation` / `Doc` / `TextEvent` / `PersonProfile` / `SocialRelation` / `Knowledge` / `ConfigDef` | — | 全部 JSON 可序列化 |
**函数类型字段盘点(唯一无法跨进程序列化的东西)**
- `ToolDef.Cleaner func(string) string`
- `ChannelDef.Cleaner func(string) string`
- `StageContext.mu sync.RWMutex`~~锁~~ → 迁移后映射到跨进程锁仲裁)
- 各种 `ToolHandler`/`StageHandler`/`EventHandler`/`func()`(回调 → RPC 反向注册)
→ 这些正是共享内存 + RPC 要保的「留在进程内的回调型资源」(迁移评估 3.5)。
---
## 三、合同面 Bbridge 51 个 method id ↔ SDK 方法映射(改造基线)
> ⏹️ **已完成2026-09-03**:整数 method id 已全部平移为 RPC method 名字符串,
> 定义在 `internal/plugin/proc/protocol.go` 的 `Method*` 常量(共 60 个,含内核→插件方向)。
> 原 `tmplLinuxBridge` 与 `meta.Core<Method>` 整数表**均已删除**。
>
> 两个遗留点:`case 25``CoreFreeString`)无对应 method内存管理是 C 层特有问题);
> `io.setToolBlocks` 已定义但内核侧仍返回未实现C ABI 时代也是空实现,非回归)。
>
> 下表保留作为历史对照。
| # | method id今天 C ABI | SDK 背的方法 | 迁移后 RPC method 名(建议) |
|---|---|---|---|
| 1 | CORE_REGISTER_TOOL | RegisterTool | `tool.register` |
| 2 | CORE_REGISTER_STAGE | RegisterStage | `stage.register` |
| 3 | CORE_REGISTER_OUTPUT_CH | RegisterOutputChannel | `output.register` |
| 4 | CORE_REGISTER_PLUGIN_API | RegisterPluginAPI | `api.register` |
| 5 | CORE_INJECT_TEXT | InjectText | `io.injectText` |
| 6 | CORE_INJECT_INTERRUPT_TEXT | InjectInterruptText | `io.injectInterrupt` |
| 7 | CORE_INJECT_TEXT_NO_MEMORY | InjectTextNoMemory | `io.injectTextNoMem` |
| 47 | CORE_INJECT_INPUT_SYNC | InjectInputSync | `io.injectInputSync` |
| 8 | CORE_SET_AUTO_RESTART | SetAutoRestart | `lifecycle.autoRestart` |
| 9 | CORE_MEMORY_RECALL | Memory().Recall | `memory.recall` |
| 10 | CORE_MEMORY_COMMIT | Memory().Commit | `memory.commit` |
| 11 | CORE_MEMORY_INTROSPECT | Memory().Introspect | `memory.introspect` |
| 12 | CORE_MEMORY_MERGE | Memory().MergeEntities | `memory.merge` |
| 13 | CORE_MEMORY_PURGE | Memory().Purge | `memory.purge` |
| 14 | CORE_DOC_QUERY | DocMemory().Query | `doc.query` |
| 15 | CORE_KNOWLEDGE_SEARCH | Knowledge().Search | `knowledge.search` |
| 16 | CORE_SETTINGS_GET | Settings().Get | `settings.get` |
| 17 | CORE_SETTINGS_SET | Settings().Set | `settings.set` |
| 18 | CORE_SETTINGS_REGISTER_DEF | Settings().RegisterDef | `settings.registerDef` |
| 19 | CORE_LLM_LIST_SOURCES | LLM().ListSources | `llm.listSources` |
| 20 | CORE_LLM_SET_SOURCE | LLM().SetSource | `llm.setSource` |
| 21 | CORE_SOCIAL_GET_PERSON | Social().GetPerson | `social.getPerson` |
| 22 | CORE_SOCIAL_GET_NETWORK | Social().GetNetwork | `social.getNetwork` |
| 23 | CORE_SUBSCRIBE | Events().Subscribe | `events.subscribe`**今天空实现** |
| 24 | CORE_UNSUBSCRIBE | (退订闭包) | `events.unsubscribe`**今天空实现** |
| 25 | CORE_FREE_STRING | (内存释放) | 删除RPC 无此概念) |
| 26 | CORE_SETTINGS_GET_CORE | Settings().GetCore | `settings.getCore` |
| 27 | CORE_SETTINGS_SET_CORE | Settings().SetCore | `settings.setCore` |
| 28 | CORE_SETTINGS_LIST_CORE | Settings().ListCore | `settings.listCore` |
| 29 | CORE_SETTINGS_GET_PLUGIN | Settings().GetPlugin | `settings.getPlugin` |
| 30 | CORE_SETTINGS_SET_PLUGIN | Settings().SetPlugin | `settings.setPlugin` |
| 31 | CORE_SETTINGS_LIST_PLUGIN | Settings().ListPlugin | `settings.listPlugin` |
| 32 | CORE_DOC_INSERT | DocMemory().Insert | `doc.insert` |
| 33 | CORE_DOC_REMOVE | DocMemory().Remove | `doc.remove` |
| 34 | CORE_DOC_STATS | DocMemory().Stats | `doc.stats` |
| 35 | CORE_KNOWLEDGE_ADD | Knowledge().Add | `knowledge.add` |
| 36 | CORE_KNOWLEDGE_LIST | Knowledge().List | `knowledge.list` |
| 37 | CORE_LLM_CURRENT_SOURCE | LLM().CurrentSource | `llm.currentSource` |
| 38 | CORE_SOCIAL_GET_TRAIT | Social().GetTrait | `social.getTrait` |
| 39 | CORE_SOCIAL_GET_RELATIONS | Social().GetRelations | `social.getRelations` |
| 40 | CORE_SOCIAL_LIST_PERSONS | Social().ListPersons | `social.listPersons` |
| 41 | CORE_TEXT_MEMORY_APPEND | TextMemory().Append | `textmemory.append` |
| 42 | CORE_SETTINGS_LIST | Settings().List | `settings.list` |
| 43 | CORE_SETTINGS_DEFS | Settings().Defs | `settings.defs` |
| 44 | CORE_SETTINGS_DUMP | Settings().Dump | `settings.dump` |
| 45 | CORE_SETTINGS_PLUGINS | Settings().Plugins | `settings.plugins` |
| 51 | CORE_SETTINGS_DATA_DIR | Settings().DataDir | `settings.dataDir` |
| 46 | CORE_REGISTER_INPUT_CH | RegisterInputChannel | `input.register` |
| 48 | CORE_PLUGIN_RELOAD_ONE | PluginMgr().ReloadOne | `plugin.reloadOne` |
| 49 | CORE_PLUGIN_LIST_LOADED | PluginMgr().ListLoadedPlugins | `plugin.listLoaded` |
| 50 | CORE_PLUGIN_IS_DISABLED | PluginMgr().IsPluginDisabled | `plugin.isDisabled` |
**bridge 侧反向调用(内核 → 插件RPC 的另一半)**
| 今天 | 迁移后 |
|---|---|
| `go_invoke_tool(name, argsJSON)` | `tool.invoke`homed → pinvoke |
| `go_invoke_stage(stage, ctxJSON, resultOut)` | `stage.invoke`homed → pinvoke共享内存数据面 |
| `go_invoke_output(channel, type, payloadJSON)` | `output.invoke`homed → pinvoke |
| `go_free_string` | 删除 |
---
## 四、合同面 CStageContext 跨 ABI 现状 → 共享内存目标
> ✅ **已达成2026-09-03**:子进程插件现在看到全部 18 个字段(枚举见
> `internal/plugin/proc/shm.go`且可写回。生产实测sanitizer 在另一个进程里
> 改写 13590 字节文本,内核读到改写结果(`stage post_action 改写了 1 个字段`)。
### C1. 迁移前C ABI 副本模型):插件只看到 10 个字段
`stageContextWritable`templates.go:762下发/回传的字段:
```
raw_message user_id group_id phase llm_text final_text no_memory
+ response可选 + tool_calls有才传 + tool_results有才传
```
**看不到的 6 个字段**`ContextMsgs` / `ReasoningContent` / `TokenUsage` / `Memory` / `Extra` / `Errors`
### C2. 迁移后(共享内存 + 锁仲裁):插件可看到/改写全部字段 — ✅ 已实现
字段级 `Slice{Off,Len}` 描述符 + 内核仲裁锁。插件进程内保留原生 `StageContext`
handler 照常读写,`Lock/RLock` 映射到跨进程锁仲裁 RPC`stage.lock`/`stage.unlock`
handler 返回时脏字段写回共享段。
**关键设计决定**:全部子进程插件共享**同一块 memfd**。第一版设计是每插件一段,
那会退化成副本模型,复现 §8.4 的 35.8~36.8% lost update。
**接口形式不变,能力变强**(能力断层消除:外部插件拿回 ContextMsgs 等)。
Windows 同步受益:从「只下发 3 字段、无写回」升到全字段可见 + 写回,
与 Unix 共用同一套 RPC 实现与共享段布局。
### C3. lost update 的合同面定义 — ✅ 已消除
C ABI 时代 `stageContextWritable` **无条件回传 10 个字段的当前快照**——两个插件
sanitizer 改 ToolResults + weather 只读并行时weather 的回传会覆盖 sanitizer
的清洗结果(实测 1.6~4.3%,高并发下 35.8~36.8%)。
Part 0.2 先做了过渡补丁只回传真正变更的字段Part 4 的共享内存模型从根上解决
(字段级描述符 + 锁仲裁,并发改写同一对象)。
回归基线:`TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate`
`TestSegment_ProductionScenario_SanitizerNotOverwrittenByWeather`
---
## 五、外部插件实际触达面example 18 插件实测汇总)
> 这是「17 个存量插件业务代码零改动」的直接依据——它们**只用**下表这些 API全部在公开 SDK 合同面内。
| 插件 | 用到的 SDK 触达 |
|---|---|
| qq最复杂 | SetAutoRestart / RegisterDef×11 / RegisterOutputChannel(qq, 4 caps) / RegisterInputChannel(qq, NoMemory+Cleaner) / RegisterStage(BeforeToolcall, OwnTools) / RegisterTool×N / InjectInterruptText×2 / getSetting(p.sdk.Settings()) |
| weather / rss / bili / ocr / files / memo / music / a2a / acp / ai_image / browser / calendar / editdoc / recoverydiag / sanitizer / vanblog / luademo | RegisterTool / Settings / SetAutoRestart / (部分) RegisterStage / RegisterOutputChannel / InjectInputSync / Knowledge / RegisterStopHandler / RegisterOnRemoveHandler |
**结论**:外部插件触达面 ⊆ 公开 SDK 合同面;无任何插件直接使用方法 id 或 bridge 内部符号。
→ 只要公开 SDK 签名不变 + bridge 语义平移,接口不变约束成立。
---
## 六、迁移后外部插件「新获得」的能力(合同面扩展——只增不减)
| 能力 | 迁移前 | 迁移后 | 实际结果 |
|---|---|---|---|
| 事件订阅 `Events().Subscribe`case 23/24 | ❌ 空实现 | ✅ 事件环EvtRing + eventfd + 独立游标) | ✅ 已接线(当前零用户) |
| `SetToolBlocks` 多模态注入 | ❌ 空实现 | ✅ 二进制落 arenaSlice 描述符回传 | ⚠️ method 已定义,内核侧仍未实现 |
| `ContextMsgs`/`ReasoningContent`/`TokenUsage`/`Memory`/`Extra`/`Errors` | ❌ 看不到 | ✅ 共享内存全字段 | ✅ 18 字段全可见可写 |
| 插件崩溃隔离 | ❌ panic 带崩 homed | ✅ 子进程独立崩溃 | ✅ 测试 + 生产验证 |
| 热重载 `.so` | ❌ `DF_1_NODELETE` no-op | ✅ 同路径替换 `.bin` 即生效 | ✅ 生产实测 |
| 工具超时取消 | ❌ cgo 不可中断(泄漏线程) | ✅ `Process.Kill()` 真取消 | ✅ 整套新架构零 cgo |
| `output_send` 结果 | ❌ 永远假成功 | ✅ 可同步等真实结果 | ✅ 生产实测 `map[status:sent]` |
| Lua/Windows DLL 路径 | ❌ 三套 ABI 分裂 | ✅ 收敛为单一 RPC 实现 | ⚠️ Windows 已收敛Lua 仍独立(留待后续) |
**三项未完全兼得的说明**
- `SetToolBlocks``io.setToolBlocks` 已在 protocol 定义并划入 `CapCore`,但内核侧 handler
仍返回未实现。C ABI 时代它也是空实现§1.4),故**不是回归**,但也没兑现承诺。
- Lua`lua_plugin.go`/`dynamic_lua.go` 仍走自己的路径。Lua 经解释器不经 C ABI
不属于本轮要消除的 6 类缺陷,因此不阻塞。收敛第三套 ABI 是独立优化。
- 事件订阅:机制已完成(内核侧 `EvtRing` + 模板侧 `evtConsumerLoop`
但**无任何现有插件使用 `Events().Subscribe`**,所以生产上未经真实负载检验。
**刻意不给**(权限梯度显式化,非技术限制):`SelftestAPI`/`SupervisorAPI`/`TrackerAPI`/
`StatusAPI`/`AdapterAPI`/`ConfigAPI`/`ToolAPI`/`IndexerAPI`/`OutputChanRaw`/`EventPublish`
(内核内部机制)。清单与理由记在 `internal/plugin/proc/capability.go`
`withheldCapabilities``TestCapability_WithheldListIsDocumented` 守护。
这一项从「C ABI 表达能力的意外产物」变成**显式策略**:以前拿不到是因为
C 结构体不好传函数指针(那是运气,任何人给 dispatch 加个 case 就能捅穿);
现在是三道闸:类型层(`procCore` 命名字段不嵌入)+ 能力集manifest 声明)
+ RPC 边界(返回明确错误而非静默忽略)。
---
## 七、接口冻结检查点(全部已通过)
1.**阶段 2子进程通道原型**`plugindev` 重编 weather → `plugin.bin` → 端到端跑通。
验收weather 业务代码逐字节未改(`git status example/` 无输出)。
2.**阶段 3共享内存**:子进程并发改写 StageContext 丢失率 = 0%
`TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate`
`TestSegment_ProductionScenario_SanitizerNotOverwrittenByWeather`)。
3.**阶段 5**17 个外部插件全部 `.bin` 化、cabi 删除(-3198 行);
`go build ./...` 与全仓 `go test ./...` 均通过。
4.**全程**`git diff third_party/homeagent-sdk/sdk/` 为零——接口冻结的硬证据。
生产端到端2026-09-03真实 QQ 消息):
```
input from qq → response (83293ms, tools=[qq_get_message qq_get_history
output_send__qq output_send__qq qq_mark_read])
[sanitizer] cleaned 2 bytes (before=13590 after=13588)
[proc] sanitizer stage post_action 改写了 1 个字段
tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
```
---
## 八、关联文档
- `docs/zh/架构迁移评估.md` — 完整论证§3.2 method id 平移、§3.3 数据面、§3.4 SDK 封装、§3.5 回调型资源、§3.8 能力对齐)
- `docs/zh/plugin-migration-plan.md` — Part 0~6 执行计划与完成实录(含 Part 6.5 生产切换、Part 6.6 压测)
- `plan.md` §11 — 11.1~11.9 修复清单(唯一权威编号)
- `third_party/homeagent-sdk/sdk/` — 合同面 A 的代码实现(全程零 diff
- `internal/plugin/proc/protocol.go` — 合同面 B 的代码实现(`Method*` 常量,取代已删的 bridge 模板)
- `internal/plugin/proc/shm.go` — 合同面 C 的代码实现(共享段布局与 18 字段枚举)
- `internal/plugin/proc/capability.go` — 权限梯度capability 组 + `withheldCapabilities`
- `third_party/homeagent-sdk/tools/plugindev/templates/` — 子进程运行时模板(三文件)
- `docs/zh/experiments/plugin-arch/` — 18 项可行性实验 + `19-migration-verify/` 迁移执行期工具

View File

@ -0,0 +1,632 @@
# 外部插件多进程化适配计划(修改→审查→验证三步微循环)
> 分支:`update`
> 基线:`docs/zh/plugin-interface-matrix.md`(合同面 A/B/C+ `plan.md` §11 + `docs/zh/架构迁移评估.md`
> 每部分 = 一个「修改 → 审查 → 验证」三步微循环。所有验证在 **update 分支**完成,可独立交付、可回退。
>
> **循环的铁律**(每部分适用):
> - **修改**:只动核心侧 + 工具链,`third_party/homeagent-sdk/sdk/`(合同面 A**零 diff**。
> - **审查**:接口冻结检查(`git diff` 公开 SDK 为空)+ 代码 review + `go vet`。
> - **验证**`make test` + 针对性单测 + 端到端冒烟,产物 `.bin` 端到端可用。
>
> 标 `【M】`=修改部分、`【R】`=审查部分、`【V】`=验证部分。依赖前置部分完成后才可开始。
---
## 目录
- **Part 0** 脆弱基线先行(不依赖迁移,现网可直接受益)— 0.1 ✅ / 0.2 ✅ / 0.3 ⏭️ / 0.4 ⏭️
- **Part 1** 加载分派骨架(`entry` 双通道共存)— ✅ **已完成**
- **Part 2** 子进程通道原型spawn / JSON-RPC / procPlugin— ✅ **已完成**
- **Part 3** plugindev 工具链改造(`.bin` 产物)— ✅ **已完成**
- **Part 4** 共享内存数据面StageContext 跨进程并发改写)— ✅ **已完成**(段/编解码/锁仲裁 + RunStage 接线)
- **Part 5** 通知面(事件环 + eventfd— ✅ **核心已完成**
- **Part 6** 迁移与收尾17 插件逐个 + 删 cabi + 权限显式化)
- 最终验收清单
> **进度快照2026-09-02**:分支 `feature/plugin-proc-migration`。
> 已交付:现网止血 2 项11.1/11.3、entry 双通道分派、共享内存 stage 并发、
> 子进程控制面NDJSON RPC + 51 method 名平移、plugindev `.bin` 构建、
> registry 接线、**事件环§3.6**。**外部插件已可端到端跑在子进程 + 共享内存上**
> 且首次获得事件订阅能力C ABI 下 case 23/24 一直是空实现)。
> 测试:内核 `internal/plugin/proc` 38 项 + `internal/plugin` 16 项(含 `-race`
> SDK 仓 plugindev 16 项。
> 下一步Part 6 逐插件迁移 + 删 `internal/plugin/cabi/`。
---
## Part 0脆弱基线先行阶段 0~1 人日)
> 依据plan.md §11.1/11.3/11.6。不依赖任何新架构,独立交付,现网直接受益。
> 目的:在副本模型内部打补丁,止血,为后续迁移争取时间。
### 0.1 output_send 假成功修复11.1)— ✅ **已完成**2026-08-31
- 【M】✅ `internal/plugin/cabi/loader.go`——`CORE_REGISTER_OUTPUT_CH`:454的异步 output 从「goroutine 直接返回 queued」改为「goroutine + 带超时 channel 等真实结果」。
新增 `awaitOutputResult`:276+ 可注入版 `awaitOutputResultWith`:281+ 常量 `outputSendTimeout = 10s`
```go
resCh := make(chan error, 1)
go func() { resCh <- invoke(pid, channel, argsJSON) }()
select {
case err := <-resCh:
if err != nil { return nil, err } // 真实失败上报
return map[string]interface{}{"status": "sent"}, nil
case <-time.After(timeout):
return map[string]interface{}{"status": "unconfirmed", "note": "..."}, nil
}
```
关键:`dev.Execute` 由 `executeOutputSendTool` 从 Go 侧调起(不在 cgo 栈内goroutine 内的 `pluginInvokeOutput` 才是 cgo**不构成嵌套**。
- 【M】✅ `internal/agent/core/output.go` `executeOutputSendTool`:识别 `status=unconfirmed|queued` → 返回「发送结果未确认:<note>」而非「已发送」,把未确认状态透传给模型。
- 【R】✅ 无 cgo 嵌套(`awaitOutputResult` 只在 `RegisterOutputChannel` 的 handler 内被调用,该 handler 从 Go 侧调起);
「超时未确认」措辞与 11.2 的"已取消"谎言区分——用 `unconfirmed` + 显式 note不谎报成功也不谎报失败。
- 【R】✅ 接口冻结:`git diff third_party/homeagent-sdk/sdk/` 为空。
- 【V】✅ 新增 `internal/plugin/cabi/output_test.go` 三用例全绿:
- `TestAwaitOutputResult_Success` → `status=sent`
- `TestAwaitOutputResult_Failure`(模拟 meta 缺 user_id→ **返回 error**(旧实现会谎报成功)
- `TestAwaitOutputResult_Timeout` → `status=unconfirmed` 且不返回 error
- 【V】✅ `go build ./...` exit 0`go test ./internal/plugin/... ./internal/agent/...` 全绿。
### 0.2 stage lost update 补丁11.3)— ✅ **已完成**2026-08-31
- 【M】✅ `templates.go`**SDK 仓** update 分支 `5648519``go_invoke_stage` 改为 diff 回传:
- 新增 `snapshotWritable(sc) map[string]string`——handler 前的**序列化**快照
- 新增 `changedFieldsOnly(before, after)`——只回传变更字段,无变更零回传
- ❗ **第一版踩坑并修正**`stageContextWritable` 返回的切片字段与 `sc` **共享底层数组**handler 原地改元素(`sc.ToolResults[0].Result = clean`)时 before 快照跟着变diff 看不到变更 → 修复会静默失效。故 before 必须逐字段序列化成字符串。
- 【M】✅ `internal/plugin/cabi/loader.go` `applyStageResult` 配套(本仓 `9bb9cb3``tool_calls`/`tool_results` 去掉 `len(v)>0` 拦截——改为键存在即应用,使插件「清空全部工具调用」的显式 `[]` 能被表达(旧插件仅 len>0 才带键,不会被误清空)。
- 【R】✅ `changedFieldsOnly` 无竞态(纯函数,无共享状态);只读插件零回传(单测断言)。
- 【R】✅ 接口冻结:两仓 `git diff sdk/` 均为空(只改 bridge 模版 + 内核)。
- 【R】✅ bridge 模版可编译性:抽取 `tmplLinuxBridge` + 真实 `weather/plugin.go` 做 `go build -buildmode=c-shared` → exit 0。
- 【V】✅ SDK 仓 `tools/plugindev/stagediff_test.go` 6 用例全绿:
- `_ReadOnlyPluginReturnsNothing`(只读插件零回传——修复核心)
- `_WriterReturnsOnlyChanged`(原地改切片元素仅回传 tool_results
- `_ScalarChange` / `_NewResponseIsReturned` / `_ClearedSliceIsReturnedAsEmpty`
- `_ProductionScenarioNoOverwrite`**复刻实验 13 现网场景**sanitizer 清洗 + weather 只读,清洗结果不再被覆盖)
- 【V】✅ 内核侧 `output_test.go` 新增 `TestApplyStageResult_ClearedSlicesAreApplied` / `_OnlyPresentKeysApplied` 全绿。
- 【V】✅ `go build ./...` exit 0`go test ./internal/plugin/... ./internal/agent/...` 全绿。
- ⚠️ **待部署项**:需用新 plugindev 重编全部 17 个外部插件bridge 模版变更),走 `plugin_install(overwrite=true)`。
### 0.3 reload 语义修正11.6)— ⏭️ **已跳过**2026-08-31 用户决策:直接进入进程化重构)
> 子进程模型下 `DF_1_NODELETE` 议题**整体消失**§3.1)——同路径替换 `plugin.bin` 重启进程即生效。
> 在 cabi 路径上补 ELF 检测属于「给即将删除的代码打补丁」,性价比低。
> 现网仍受 reload 假成功影响,但 Part 1 的 entry 分派已为迁移铺路,迁移完成即根治。
- 【M】`dynamic_loader_unix.go`ELF 检测 `DF_1_NODELETE` → 标记"不可热重载"。
- 【M】`registry.go` 的 `ReloadOne`:对此类插件返回"需重启 homed"。
- 【M】`pluginmgr/plugin.go` 的 `plugin_install`:返回 `restart_required` 替代 `reload_required`。
- 【R】确认 `.so` 插件重载不再"假成功"。
- 【V】单测mock ELF 头带 NODELETE vs 不带 → 正确区分。
### 0.4 超时日志措辞修正 + 附带11.2 短期项 + 11.4)— ⏭️ **已跳过**(同上)
> 11.2 的 cgo 超时不可中断在子进程模型下由 `Process.Kill()` 真正解决§9.5
> 11.4 的 Lua 路径在迁移后统一走 RPC三套 ABI 收敛),锁语义天然有边界。
- 【M】`internal/agent/core/toolcall.go:41`:日志从"已取消"改为"已放弃等待(插件仍在后台运行,其占用的线程无法回收)"。
- 【M】`internal/plugin/lua_plugin.go:726`stage 快照加 `sc.RLock()`/`RUnlock()`11.4)。
- 【R】措辞语义诚实Lua 快照持锁。
- 【V】`make test` 全绿;超时日志不再撒谎。
**Part 0 出口条件**11.1/11.3/11.6 全部落地并有针对性测试;生产可先部署(现网止血)。
---
## Part 1加载分派骨架阶段 2.4S
> 依据:迁移评估 §2.4 / 3.2plan.md 11.7。目标:让 registry 能按 entry 把插件分派到 `.so`cabi或 `.bin`proc两条通道——**双通道共存是整个计划可回退的前提**。
### 修改(核心)
- 【M】`internal/plugin/manifest.go``PluginManifest.Entry` 注释与 `IsPluginDir` 支持 `plugin.bin`。
- 【M】`internal/plugin/dynamic.go`:新增 `binEntry = "plugin.bin"` 常量;`readManifest` 读取 entry。
- 【M】`internal/plugin/registry.go` `loadOne`~:376把「无工厂 → `tryDynamic`」的分支改为按 entry 分派:
```go
switch entry {
case soEntry, dllEntry: p, err = r.tryLoadSO(...) // 现有 cabi
case binEntry: p, err = r.tryLoadProc(...) // 新增Part 2 填充)
default: p, err = r.tryOther(...) // lua / skill
}
```
先保留一个 `tryLoadProc` 桩(返回"未实现"错误),保证分派骨架先成立、可测。
- 【M】`internal/plugin/dynamic_loader_unix.go`:把 `tryLoadSO` 从 `tryDynamic` 拆出成 registry 可独立调用的函数。
### 审查
- 【R】确认内置插件`hasFactory` 分支)完全不受影响——仍走 `RegisterNative` 进程内路径。
- 【R】确认 `.so` 路径行为与今天逐字节一致(无回归)。
- 【R】接口冻结`git diff` 公开 SDK 为空。
### 验证
- 【V】单元测试mock 三种 manifestso/dll/bin/lua→ 分派到正确通道;`.bin` 桩返回明确错误而非 panic。
- 【V】既有 `.so` 插件加载 e2e 不回归(带一个真实 .so 冒烟)。
**Part 1 出口条件**:分派骨架在,`.bin` 有明确桩位,`.so` 全回归。
#### ✅ **Part 1 已完成**2026-08-31commit `610e9d0`
- 【M】✅ `dynamic.go`:新增 `binEntry`/`skillEntry` 常量 + `entryKind` 枚举 + `classifyEntry` / `detectEntryKind`
- **manifest 的 entry 优先级最高**——把 entry 改回 `plugin.so` 即回退 cabi 通道(回退路径的保证)
- 无 manifest 时按目录探测,`.bin` 优先于 `.so`(迁移期同目录两产物共存时走新通道)
- 【M】✅ `registry.go` `tryDynamic`:按 entry 分派 proc/cabientry 声明 `.bin` 但二进制缺失时**报明确错误,不静默回退**
- 【M】✅ `registry.go` `pluginEntryHash`:候选顺序与 `detectEntryKind` 对齐(`.bin` 优先),否则增量重载会用错文件算 hash
- 【M】✅ `manifest.go``Entry` 字段注释补 `plugin.bin`
- 【M】✅ `dynamic_proc_unix.go` / `dynamic_proc_windows.go``tryLoadProc` 桩位(存在性/类型/可执行权限校验已实现)
- 【R】✅ 内置插件(`hasFactory` 分支)完全未受影响——仍走进程内 `RegisterNative`
- 【R】✅ `.so` 路径行为与改动前一致(既有测试全绿,无回归)
- 【R】✅ 接口冻结:`git diff third_party/homeagent-sdk/sdk/` 为空
- 【V】✅ `entry_dispatch_test.go` 9 项全绿:
- `TestClassifyEntry`8 种 entry 分类)
- `TestDetectEntryKind_ManifestWins` / `_ManifestCanForceRollback`**回退路径验证**
- `TestDetectEntryKind_ProbeOrderPrefersBin` / `_ProbeFallbacks`4 子例)
- `TestTryLoadProc_MissingBinaryReturnsNil` / `_NonExecutableRejected`
- `TestPluginEntryHash_PrefersBin` / `_EmptyForFactoryOnlyPlugin`
- 【V】✅ `go build ./...` exit 0`go test -race ./internal/plugin/...` 全绿;全量 32 个包测试通过
---
## Part 2子进程通道原型阶段 2.1~2.3/2.5/2.9~3 周,核心风险点)
> 依据:迁移评估 §4.1 阶段 2迁移评估指明可大幅参考 `clawhubadapter/sidecar.go:54-350`(已有 stdin/stdout + pending map + notifyCh
> 目标把单个外部插件weather以 `plugin.bin` 端到端跑通,验证"接口不变"假设。
### 修改(核心)
- 【M】新建 `internal/plugin/proc/`
- `process.go`——`procPlugin` 实现 `sdk.Plugin` 接口;`spawn`/健康检查/优雅停止/`Close()`=真 kill+wait。
- **可参考** `clawhubadapter/sidecarProcess``exec.Cmd` + `stdin *bufio.Writer` + `readLoop`scanner 大 buffer 64KB+ `pending map[int]chan<- []byte` + `notifyCh chan OCNotification` + readerStop/readerWg。
- `rpc.go`——双向 JSON-RPC 编解码7 个 kernel→plugin 调用(`tool.invoke`/`stage.invoke`/`output.invoke`+ 51 个 plugin→kernel 回调(平移自合同面 B 映射表)。
- 【M】`internal/plugin/dynamic_loader_unix.go`:实现 `tryLoadProc`spawn `.bin`,回连 stdio RPC
- 【M】`internal/plugin/registry.go` `closePlugin`/卸载路径:对 proc 插件 `Close()` 真 kill。
- 【M】`internal/agent/core/plugin_health.go` 调用侧:插件**退出码/EOF** → `recordCrash`**逻辑完全复用**,仅把"panic 捕获"换成"进程退出检测",见迁移评估 §2.3)。
### 审查
- 【R】`readLoop` 鉴权:只接受来自本进程 spawn 的 stdout防注入
- 【R】JSON-RPC 帧边界处理(`bufio.Scanner` 长行截断风险——沿用 sidecar 64KB buffer
- 【R】pending map 泄漏:超时清 map、退出时清 map。
- 【R】崩溃重启`SetAutoRestart(true)` 语义保留;`plugin_health` 冷却/自愈复用。
- 【R】接口冻结公开 SDK 零 diff。
### 验证
- 【V】单测spawn→握手→工具调用往返→正常 Stop→kill 崩溃→退出码捕获。
- 【V】weather `.bin` 端到端:`RegisterTool`/`Settings`/`InjectInputSync` 全部经 stdio RPC 打通。
- 【V】与 Part 1 的 entry 分派联动:同目录 `.so` 与 `.bin` 共存互不干扰。
**Part 2 出口条件**:一个真实外部插件 `.bin` 全链路可用,崩溃隔离生效,接口零改动。
#### ✅ **Part 2 已完成**2026-09-01commit `d62430a` + `82dcc86`
- `proc/protocol.go`NDJSON 帧、**51 个 method id 平移为 method 名**(编号扔掉)、握手/stage/tool/output 参数类型。
`case 25`(CORE_FREE_STRING) 无对应 methodGC 接管);`case 23/24`(事件订阅) 与 `io.setToolBlocks`
明确返回未实现,**不静默成功**。
- `proc/process.go`Spawn/readLoop/CallContext/Notify/Stop/Kill/markExited单帧上限 1MB。
- `proc/corehandler.go`51 case 平移 + `CoreSDK` 接口(**刻意排除**内核内部机制,见 Part 6 权限梯度)。
- `proc/host.go`**全部插件共享同一 memfd**。最初写成每插件一块段,尝试后发现
那等于**副本模型换壳**(各写各段、各自回读、最后回读者覆盖前者),已改正。
- `proc/stage.go`RunStage 接线 + lockRegistry`proc/plugin.go`Plugin 实体。
- 共享段分配按平台拆分(`shmalloc_linux.go` memfd / `shmalloc_darwin.go` 立即 unlink 的临时文件 /
`shmalloc_other.go` 明确报错)——不静默降级成「无共享段」,那会让 stage 静默失去数据面。
- registry 接线commit `11c1bbc``tryDynamic` → `Registry.loadProc`Host 惰创建且全局唯一;
`StopAll` **锁外**释放共享段(插件还持有映射时拆段 → SIGBUS持锁调与 onProcCrash 有锁序风险);
`onProcCrash` 只发 EventSystem 事件,**不在回调里直接重载**(重载需 registry 锁)。
- `proc_core.go` —— 权限梯度的类型系统落点:`procCore` 用**命名字段**持有 `*isdk.PluginSDK`
不是嵌入。嵌入会提升全部方法,外部插件就能经类型断言拿到
Supervisor/Tracker/Adapter/Indexer/Status/Selftest。
- 测试 36 项含 `-race``testdata/` 8 个假插件 + `e2e_template_test.go` 用**真实 plugindev 模板**
编译插件跑全链路(验证「模板 ↔ 内核」协议/布局真的对齐,不只是内核自己跟自己对齐)。
---
## Part 3plugindev 工具链改造(阶段 2.6/2.7/2.8MSDK 仓)
> 依据:合同面 B迁移评估 §4.1。此部分在**独立 SDK 仓**维护(用户决策 sdk_repo_only
> 目标:让外部插件能用普通 `go build` 产出 `.bin`,业务代码零改动。
### 修改(工具链)
- 【M】`tools/plugindev/templates.go`:新增 `tmplProcMain`——把 bridge 从「7 个 `//export` + `-buildmode=c-shared`」改为「`main()` + stdio JSON-RPC loop」注册逻辑`buildPluginSDK` 的 registar 闭包)从 `callVoid(id,...)` 改为 `sendRPC(methodName,...)`(合同面 B 的平移)。
- 【M】`tools/plugindev/cmd_build.go`
- 新增目标 `plugin.bin``go build`(去 `-buildmode=c-shared`、`CGO_ENABLED=0`)→ `plugin.bin`。
- bundle 平台表:`{"linux/amd64","plugin.bin"}`(替代 `.so`)。
- `resolveBuild`bin 分支不再需 C 编译器。
- 【M】`tools/plugindev/cmd_build.go` `validBinaries`/打包:`.hmap` 内条目支持 `plugin.bin``plugin.json` entry 写 `plugin.bin`)。
- 【M】`plg.json` 模板(`tmplPlgJSON``entry` 默认改为 `plugin.bin`(保留 `.so` 兼容)。
### 审查
- 【R】生成的 `tmplProcMain` 与旧 bridge 的 SDK 方法一一对应(对照合同面 B 51 行映射表逐行核对)。
- 【R】业务代码**零改动**证据:同一 `plugin.go`,仅入口文件/构建命令不同。
- 【R】交叉编译简化确认`.bin` 无需 cgo 工具链,跨 GOOS 仅需目标 toolchain。
### 验证
- 【V】用新 plugindev 重编 `example/weather` → 产出 `plugin.bin`。
- 【V】`.hmap` 打包/解包校验:`plugin.bin` 条目正确登记。
- 【V】与 Part 2 集成weather.bin 被 homed proc 通道正确加载运行。
**Part 3 出口条件**plugindev 一条命令产出 `.bin` + 正确 `.hmap`,外部插件源码零改动。
#### ✅ **Part 3 已完成**2026-09-02SDK 仓 commit `09b64dc`
**模板落地方式换了**:不是计划里的 `templates.go` 新增 `tmplProcMain` raw string
而是真实 `.go` 源文件 `templates/proc_main.go.tmpl` + `//go:embed``proc_runtime.go`)。
原因900+ 行代码塞在字符串里写错只能等生成插件时才炸,作为源文件可被
`go/parser`、`gofmt`、`go vet` 直接检查。这也是 `proc_runtime_test.go` 16 项
静态检查得以存在的前提。
- `templates/proc_main.go.tmpl`1113 行51 个 method 的插件侧 RPC 实现
`procIO`/`procMemory`/`procSettings`/`procSocial`/`procLLM`/`procKnowledge`/
`procDocMemory`/`procTextMemory`/`procPluginMgr`、共享段访问fd 3与 16 字段
StageContext 编解码、`handleStageInvoke`(拿锁 → 读段 → handler → **只写脏字段** → 放锁)。
- `cmd_build.go``resolveBuild(target, proc)` 分派proc 走 `go build -trimpath` + `CGO_ENABLED=0`
**交叉编译不再需要目标平台 C 工具链**。bundle 模式各平台产物同名(进程边界即 ABI 边界,
无平台扩展名),故 zip 内加平台后缀 `plugin.bin.linux.amd64`。
- `proc_runtime.go`:生成时清理残留 `z_bridge_gen.go`/`z_entry.c`——同目录两套 main 会编译冲突,
这让 `.so` → `.bin` 切换无需人工清理。
**计划外补的一个真缺口**`lifecycle.autoRestart` 没接线。公开 SDK 的 `SetAutoRestart`
是纯 setter`s.autoRestart = enabled`,无回调 hook。C ABI 下内核在 `Start` 返回后
直接读 `plgSDK.AutoRestart()`;子进程隔着进程边界读不到,插件调它只改自己进程内的副本。
修法:模板在 `plg.Start()` 返回后显式上报一次(内核侧 `corehandler.go:145` 早已就绪)。
**没有改公开 SDK 接口**。
验证(均已实测):
```
$ plugindev build # plg.json: entry = "plugin.bin"
compiling linux/amd64 (子进程模式CGO_ENABLED=0)...
packaged weather_linux_amd64.hmap
build/plugin.bin → ELF 64-bit executable, statically linked ← 零 cgo
dist/*.hmap → plugin.json + plugin.bin
$ diff example/weather/plugin.go <构建目录>/plugin.go
✅ 逐字节一致 ← 业务代码零改动的硬证据
$ git diff third_party/homeagent-sdk/sdk/
(空) ← 接口冻结保持
```
---
## Part 4共享内存数据面阶段 3.1~3.5~3 周,最高风险)
> 依据:迁移评估 §3.3 数据面 / 3.4 SDK 封装 / 3.7 锁仲裁;合同面 C。
> 目标:多插件并发改写同一 `StageContext` 语义与今天一致(丢失率 → 0外部插件看到全部 16 字段。
### 修改
- 【M】`internal/plugin/proc/` 新增 `shm.go`
- 共享段 schema`ShmStageCtx` + `Slice{off,len}` 偏移描述符 + arenaappend-only + 压实)。
- arena 分配器:插件把 `FinalText` 从 10B 改 10KB 时分配新区域、旧区域留垃圾、stage 结束后压实。
- 4 个 `Extra` 键media_blocks/media_type/input_source/output_channel提升为具名字段迁移评估 §3.3 已核实全部使用点)。
- 段生命周期:创建/挂载/插件崩溃后清理。
- 【M】`internal/plugin/proc/shmcodec.go``StageContext` ↔ 共享段编解码偏移↔Go 值转换)。
- 【M】`internal/plugin/proc/lock.go`**锁仲裁 RPC**——插件 `Lock/RLock` → `stage.lock`/`stage.unlock` → 内核 `sync.Mutex` 排队(迁移评估 §3.7 已裁定,实验 3+9 支撑)。
- 【M】`internal/agent/core/stages.go` `RunStage`:改造为跨进程并发扇出(**保留并发语义,最难一环**)——内置插件仍进程内 `go func`,外部插件走共享段 + 锁仲裁。
- 【M】SDK 侧(插件进程内)封装全部复杂度(迁移评估 §3.4):插件保留原生 `StageContext`handler 照常读写,脏字段写回共享段。
### 审查(最高优先级 review
- 【R】**并发语义一致性**内置0% 丢失)与外置(迁移前 35.8~36.8%)在共享内存下都收敛到 0% 丢失。
- 【R】锁仲裁死锁持锁进程崩溃自愈实验 9 已证无需 robust mutex
- 【R】arena 单 stage 写入上限:大写入在 SDK 层**报错**而非静默截断(迁移评估 §4.4)。
- 【R】`Extra` 不引入通用 tagged union 成本(维持 4 键具名字段)。
- 【R】接口冻结`sdk/` 零 diff`StageContext` 结构体字段序不变。
### 验证
- 【V】复刻实验 85 子进程 × 300 轮并发改写 → **零丢失零撕裂**。
- 【V】复刻实验 13 现网场景sanitizer改 ToolResults+ weather只读并发 → 清洗结果不再被覆盖。
- 【V】改写型插件行为基线测试`sanitizer`/`multimodal` 迁移前后行为对拍(迁移评估 §4.4 风险缓解)。
**Part 4 出口条件**:跨进程并发改写零丢失,内置/外置语义一致16 字段全可见。
#### ✅ **Part 4 核心已完成**2026-08-31commit `610e9d0`)—— 段 / 编解码 / 锁仲裁三件套
> 用户明确指出「基于共享内存的 stage 并发是最为关键的」,故先于 Part 2/3 落地数据面。
> `RunStage` 的跨进程接线3.4)待 Part 2 的进程通道就绪后进行。
- 【M】✅ `proc/shm.go` 段布局与 arena 分配器§3.3
- `Header(64B) + ShmStageCtx(描述符数组 + 标志位) + append-only arena`
- **相对偏移**:各进程 mmap 到不同虚拟地址仍能正确解引用
- `NewSegment` / `AttachSegment` 带魔数 + 版本校验(版本不匹配显式报错,不静默错读)
- **arena 用尽显式报错**而非静默截断§4.4 风险登记的硬要求)
- `Compact()` 回收 append-only 垃圾,须在无插件持锁时调用
- 【M】✅ `proc/shmcodec.go` StageContext 16 字段跨进程编解码§3.4
- **字段级描述符消除 lost update**:只改 `FinalText` 的插件完全不触碰 `ToolResults` 描述符
- `WriteDirty` 只写脏字段——**只读插件零写入**,不可能覆盖他人改写
- `Snapshot` 存**序列化字符串**切片共享底层数组的坑C ABI 侧修 11.3 时已踩过一次)
- `Extra` 4 键提升为具名字段;`Response` 用标志位区分 nil 与空串(短路语义)
- **全 16 字段可见**——今日经 C ABI 只有 10 个,`ContextMsgs`/`ReasoningContent`/`TokenUsage`/`Memory`/`Extra`/`Errors` 首次对外部插件可见
- 【M】✅ `proc/lock.go` 锁仲裁回归内核§3.7 已裁定,**零 cgo**
- `ForceRelease` 实现实验 9 的崩溃自愈 → 排除 robust pthread_mutex 必要性
- 重复加锁**显式拒绝**(否则死锁 30s比挂死更难排查
- 等待超时有补偿 goroutine 防锁永久泄漏
- 【R】✅ 并发语义:`TestSegment_ConcurrentAppend_NoLostUpdate` 断言「各标记计数之和 == 最终长度 且 == 期望写入次数」,同时排除丢失与撕裂
- 【R】✅ arena 上限报错(非静默截断):`TestSegment_ArenaExhaustionReturnsError`
- 【R】✅ `Extra` 维持 4 键具名字段,未引入通用 tagged union 成本
- 【R】✅ 接口冻结:`sdk/` 零 diff`StageContext` 结构体未改
- 【R】✅ `go vet` 干净(含 copylocks 检查)
- 【V】✅ proc 包共享段部分 **16 项测试全绿(含 `-race`**(全包现 36 项,含进程/端到端):
- 段:魔数/版本校验、全 16 字段往返、Response nil vs 空串
- 脏字段:只读零写回、原地改切片被识别、压实不破坏字段
- **现网场景复刻**`TestSegment_ProductionScenario_SanitizerNotOverwrittenByWeather`sanitizer 清洗 + weather 只读并发,清洗结果不被覆盖)
- **并发零丢失**5 插件 × 40 轮读-改-写同一字段200 次写入全部保留
- 锁:互斥、串扰拒绝、未持锁释放拒绝、重复加锁拒绝、**崩溃自愈**、定向强制释放、临界区串行化
#### ✅ **Part 4 RunStage 接线已完成**2026-09-0109-02
- `proc/stage.go` 把内核 `RunStage` 的并发扇出接到共享段:
`Host.beginStage`(首个到达者独占段并写入 StageContext→ `stage.invoke` RPC →
插件侧 `stage.lock` → 读段 → handler → 只写脏字段 → `stage.unlock` →
`Host.endStage`(最后离开者回读 + 压实 arena
- **并发扇出保留**§0.2 第 1 条:并发扇出是原始设计,不是缺陷);
`stageMu` 串行化整次 stage 对共享段的独占(内核可能在不同路径并发触发
RunStage而段只有一份
- 端到端验证(`e2e_template_test.go`,用**真实 plugindev 模板**编译的插件,
而非 `testdata/` 手写假插件——后者只能验证内核自己跟自己对齐):
- `TestE2E_RealTemplatePluginFullLifecycle`:握手 → init/start → 反向注册 →
工具调用 → stage 读改写;同时验证 `FinalText` 回传
**C ABI 下 after_toolcall 看不到此字段**§8.3 10→16
- `TestE2E_RealTemplateReadOnlyPluginDoesNotOverwrite`:两插件共享同一 Host 并发,
只读插件不覆盖改写插件的结果(若每插件一块段,此测试必然失败)
**Part 4 已整体完成**。
---
## Part 5通知面阶段 4.1~4.5~1.5 周)
> 依据:迁移评估 §3.6 事件环 / §2.4 约束 B / §3.8。目标:外部插件首次获得事件订阅能力,且不阻塞流式输出。
### 修改
- 【M】`internal/plugin/proc/eventring.go``EvtRing` + `Subscriber` schemawrite_seq/read_seq/dropped/type_mask/last_seen溢出计数、允许丢但让消费者知道丢了。
- 【M】eventfd 通知 + Go netpoller 消费:`unix.Eventfd(EFD_NONBLOCK|EFD_CLOEXEC)` + `os.NewFile` 注册 netpoller**不占 OS 线程**——实验 1 已证 200 goroutine 仅 +1 线程)。
- 【M】`internal/events/bus.go` `Publish`:加事件环投递(**post-and-forget绝不等待消费者**,满足约束 B
- 【M】实现 `case 23/24`(今天空实现)——`Events().Subscribe` 对外部插件真正可用。
- 【M】订阅者活性检测`last_seen` 超时 → `recordCrash`。
### 审查
- 【R】`Bus.Publish` 路径**禁用任何锁/阻塞**——流式输出逐 token 发布,任何等待都会卡顿(迁移评估 §4.3 风险高)。
- 【R】溢出语义drops 计数暴露,不静默丢。
- 【R】eventfd 计数合并1000 token 事件只唤醒几次。
### 验证
- 【V】流式压测长回复下 Publish 单次耗时不随订阅者数线性恶化。
- 【V】复刻实验 4post-and-forget 解耦5s → 2.3ms 量级)。
- 【V】外部插件订阅事件端到端原空实现 case 23/24 现在可用)。
**Part 5 出口条件**:事件订阅对外可用,流式输出无卡顿。
---
## Part 6迁移与收尾阶段 5.1~5.4~2 周)— ✅ **已完成**2026-09-03
> 依据:迁移评估 §4.5 双通道共存、§5 权限梯度。
>
> ⚠️ **实际执行偏离计划的一处**:原计划「逐插件迁移,随时回退」。
> 用户决策改为**彻底舍弃 `.so` 能力,无回退通道**(不做 `--cabi` 开关),
> 本轮直接删 `internal/plugin/cabi/`,生产全量切换。代价是某插件出问题
> 只能紧急修复或 `git revert` 整批。因此下方【V】的「`.so` ↔ `.bin` 混跑」
> 不再适用——新内核根本不认 `.so`。
### 修改
- ✅【M】**6.1** 工具链 entry 语义收敛SDK 仓 `9f84412``isProcEntry` 删除Go 插件一律产出 `plugin.bin` 不看 entry 值;`templates.go` 1296→516 行。
- ✅【M】**6.3** 17 插件全量重编(`1d7f011`16 个×3 平台 + qq×1`git status example/` 无输出(业务代码零改动)。
- ✅【M】**6.5** 生产切换(`62bdfa2`):经 `pluginmgr` 的 hmap 正规通道安装17/17 成功且 `config_kept=true`。
- ✅【M】**6.6** 压测 + 版本 1.0.0 + 文档(`2572688`、`670efcd`、tag `v1.0.0`)。
- ✅【M】**6.2** 内核侧 Windows`d027c96`+ 删 C ABI`b20121f`-3198 行):删 `internal/plugin/cabi/`(1156)、`dynamic_dll_windows.go`(272)、`dynamic_loader_unix.go`(79) + bridge 模板;新增 `shmalloc_windows.go` + `evtfd_windows.go` + `shmpass_{unix,windows}.go`;顺带修 macOS pipe 写端被 GC 回收的真 bug。
- ✅【M】**6.4** 权限梯度显式化(`2ebdb9a`54 个 method 划入 11 个 capability 组;`coreHandler.Handle` 入口强制;`withheldCapabilities` 表记录 10 项刻意不提供的内核机制及理由(`SelftestAPI`/`SupervisorAPI`/`TrackerAPI`/`StatusAPI`/`AdapterAPI`/`ConfigAPI`/`ToolAPI`/`IndexerAPI`/`OutputChanRaw`/`EventPublish`)。
- ⏭【M】`lua_plugin.go`/`dynamic_lua.go` 统一走 RPC —— **留待后续**。Lua 走解释器不经 C ABI不阻塞本轮目标消除 C ABI 前提缺陷)。收敛第三套 ABI 是独立优化。
- ✅【M】文档本文与 `plugin-interface-matrix.md` 更新;切换实录见下方。
### 审查
- ✅【R】每删一个 cabi 依赖项,`go build ./...` + `go vet ./...` 干净。
- ✅【R】权限梯度被拒 API 在 RPC 边界返回**明确错误**(非忽略)。错误消息含四要素:哪个插件、哪个调用、缺什么能力、在哪声明。`TestCapability_DeniedErrorIsActionable` 守护。
- ✅【R】接口冻结`git diff third_party/homeagent-sdk/sdk/` 全程为空。
### 验证(全量回归)
- ✅【V】17 插件经 `plugin_install(overwrite=true)` 加载,工具/设置/通道/阶段 e2e。
- ⏭【V】~~`.so` ↔ `.bin` 混跑集群冒烟~~ —— 不适用(无回退通道,见上方偏离说明)。改为验证**新内核面对旧 `.so` 给可操作错误且不崩溃**,已在真实二进制上确认。
- ✅【V】`make test` 全量绿 + `go build ./...`。
- ⚠【V】内存**未达成计划目标**。15 个插件进程 RSS=88.0MB / PSS=87.9MB,远超「基线 +29MB」。根因是每插件静态链接整个 Go runtime15 个不同二进制无共同物理页可映射PSS/RSS 99.9% vs 基线 44%)。这是「每插件独立二进制」的固有代价,实际开销高于 §4.3 乐观估计。压缩方向:共享 launcher 二进制 + 各自业务模块。
- ✅【V】工具调用 RPC 延迟 24.1µs实验 11 基线 19.6µs同量级
**Part 6 出口条件**:全部外部插件 `.bin` 化 ✅cabi 删除 ✅,接口零改动 ✅,权限显式化 ✅,无回归 ✅。
---
## 最终验收清单(对照接口不变矩阵 §7 检查点)
| # | 检查点 | 通过标准 | 结果 |
|---|---|---|---|
| 1 | 公开 SDK 接口冻结 | `git diff third_party/homeagent-sdk/sdk/` **为空**(全程) | ✅ 每次审查均确认 |
| 2 | 外部插件业务代码零改动 | 17 个 `example/*/plugin.go` 与基线逐字节可比 | ✅ `git status example/` 无输出 |
| 3 | 17 插件 `.bin` 化 | 全部经 `plugin_install` 加载,工具/设置/通道/阶段 e2e | ✅ 17/17`config_kept=true` |
| 4 | cabi 删除 | `internal/plugin/cabi/` 与 bridge 模板不存在 | ✅ -3198 行(`b20121f` |
| 5 | 崩溃隔离 | 插件 kill 只退出自身homed 存活 | ✅ `TestRealPlugin_CrashDoesNotKillKernel` |
| 6 | 热重载 | 同路径换 `.bin` 即生效,无需重启 | ✅ 生产实测(`unloaded (config kept)` → 重载) |
| 7 | 并发改写 | 跨进程 stage 丢失率 0%(对照今天 35.8~36.8% | ✅ `TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate` |
| 8 | 事件订阅 | 外部插件 `Events().Subscribe` 可用 | ✅ 事件环已接线(当前零用户) |
| 9 | 多模态 | `SetToolBlocks` 非空实现 | ⚠️ method 已定义并划入 core 能力,内核侧仍返回未实现 |
| 10 | 超时取消 | 工具超时可 `Process.Kill()`,零泄漏 | ✅ 整套新架构零 cgo |
| 11 | output_send | 真实结果返回(非假成功) | ✅ 生产实测 `map[status:sent]` |
| 12 | 权限梯度 | 内部专属 API 在 RPC 边界拒绝 | ✅ 12 项测试(`2ebdb9a` |
| 13 | 内存/延迟 | 常驻 +≤29MBRPC p50 ≤20µs 量级 | ⚠️ 延迟 24.1µs 达标;内存 88MB **未达标** |
**两项未完全达标的说明**
- **#9 SetToolBlocks**`io.setToolBlocks` 已在 protocol 定义并划入 `CapCore`
但内核侧 handler 仍返回未实现。C ABI 时代它也是空实现§1.4
故**不是回归**,但也没兑现 §3.8 的承诺。当前无插件使用。
- **#13 内存**15 个进程 RSS=88.0MB,远超「基线 +29MB」。根因是每插件
静态链接整个 Go runtime15 个不同二进制无共同物理页PSS/RSS 99.9%
vs 基线 44%)。实验 5 的基线用的是 2.68MB 最小插件,而真实插件 3.1~14.8MB
绝对数字不可比。结构性指标(均摊线程 5.5 vs 4.9)同量级。
---
## 风险与回退
| 风险 | 缓解 | 回退 |
|---|---|---|
| Part 2/4 `RunStage` 并发语义漂移 | 复刻实验 8/13 + sanitizer/multimodal 对拍Part 4 review | entry 分派切回 `.so`Part 1 双通道) |
| Part 4 `Bus.Publish` 阻塞卡顿 | 专项流式压测Part 5 | 事件环投递后置,先降级进程内 |
| Part 3 工具链 `.bin` 产物问题 | 单插件 weather 先行验证 | 保留 `.so` 构建分支 |
| Part 6 17 插件回归 | 逐个迁移 + `plugin_install(overwrite)` | 任意一个失败立即回退该插件 entry |
| 接口意外漂移 | 每部分【R】强制 `git diff sdk/` 检查 | 立即 revert暴露合同面违约 |
---
*规划2026-08-31update 分支。Part 编号与其依赖的 plan.md/迁移评估阶段对应。*
---
## Part 6.5 生产切换实录2026-09-03
### 执行顺序(先换二进制,再装包)
```
1. systemctl stop homeagent
2. 换 /usr/local/bin/homed
3. 起服务 —— 15 个 .so 插件报可操作错误被跳过homed 与 16 个内置正常
4. 逐个 POST 装 17 个 hmapoverwrite=true
5. 重启核对
```
**为何不能反过来**:若先装包,旧 homed 的 `StopAndUnload` 会停掉 qq
消息通道,而它又无法加载 `.bin`,会卡在「插件全挂」的状态。
第 3 步顺带在真实二进制上验证了 Part 6.2 的可操作错误:
```
[plugin] dynamic weather: plugin weather: 检测到旧 C ABI 产物plugin.so/.dll/.dylib
外部插件已改为子进程模式,请用新版 plugindev 重编产出 plugin.bin业务代码无需修改
```
不崩溃,只跳过该插件。
### 走 hmap 正规通道,而非手工拷贝
第一版切换脚本是手工拷 `plugin.bin` + 手改 `plugin.json` 的 entry ——
那等于**重新实现了一遍 hmap 解包逻辑,且实现得更差**。漏掉的东西:
| | 手工拷贝 | hmap 正规通道 |
|---|---|---|
| `platforms` 字段 | 漏了 | 包内 manifest 本来就写对 |
| 平台二进制选择 | 硬编码 `_linux_amd64` | `platformBinary()` 按 runtime 选 |
| `overwrite` 语义 | 无 | `StopAndUnload` **保留配置表** |
| 失败回滚 | 无 | `os.Rename` 备份,解包失败自动恢复 |
| 校验 | 只查文件存在 | `validatePackage` 查 manifest + 各平台二进制齐全 |
配置保留那条尤其关键:生产 17 个插件都有配置qq 账号、weather 默认城市、
browser profile 路径)。手工脚本恰好没碰配置表所以侥幸不丢,但那是运气不是设计。
最终实现POST 到 `127.0.0.1:9876/plugins`,传 `{path, overwrite:true}`。
保留的一个设计是**先全部校验再动手**——任一插件缺 hmap 就整批中止,
因为新 homed 不认 `.so`,「一半装了一半没装」的中间态最难排查。
### 结果
```
17/17 成功,全部 config_kept=true
0 个残留 .so17 个 plugin.bin 均有执行位
17 个 manifest 的 entry 均为 plugin.bin无 .bak 残留
bundle 包正确挑了当前平台weather 目录只留 8.7MB 的 linux/amd64 那份)
```
备份:`/home/newqqagent-migration-backup-20260902-214812`
plugins 全目录 + homed.old + homeagent.service162MB
**唯一回滚路径**是恢复该目录 + 回滚 homed 二进制。
### 生产端到端验证(真实 QQ 消息)
```
input from qq → response (83293ms, tools=[qq_get_message qq_get_history
output_send__qq output_send__qq qq_mark_read])
```
逐环节:
- **输入**qq 子进程收 webhook → 经 RPC 报给内核 → agent 主循环
- **工具调用**5 次跨进程调用全部成功(内核反向调用进子进程执行)
- **stage 改写生效**(最关键的一条):
```
[sanitizer] cleanToolCallLeakage: 2 bytes removed
[sanitizer] cleaned 2 bytes (before=13590 after=13588)
[proc] sanitizer stage post_action 改写了 1 个字段
```
sanitizer 在**另一个进程里**改了 StageContext内核读到了改写结果。
13590 字节文本经共享段传递、被改写、写回,全程未拷贝整个上下文。
- **输出真的送达**`tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]`
—— 直接验证 Part 0.1 修的 output_send 假成功缺陷§9.4
- **arena 生命周期正常**:每次 stage 结束都压实回收(单次最高 15802 字节),无泄漏累积
这一次对话触发约 20 次 stage、5 次工具调用、2 次输出发送,跨越 15 个插件子进程。
旧架构下同样流程有三处会静默出问题stage 并发写丢字段§8.4 实测 35.8~36.8%
lost update、output_send 假成功、cgo 超时泄漏 goroutine。现在这些在日志里可见且正确。
---
## Part 6.6 压测与延迟实测
基准与压测在代码里(`internal/plugin/proc/bench_test.go` + `streaming_test.go`
非独立脚本——随代码演进自动跑,不会腐坏。
| 项目 | 实测 | 基线 | 判断 |
|---|---|---|---|
| 工具调用 RPC 往返 | 24.1 µs | 实验 11: 19.6 µs | 同量级 |
| 锁仲裁(内核侧) | 0.76 µs | — | 见下注 |
| 事件环写入 | 95 ns | — | 亚微秒 |
| 事件环并发写入 | 83 ns | — | 无锁竞争恶化 |
| 完整 stage 往返 | 132 µs | — | 含 3 次进程间往返 |
| 共享段编解码 | 3.7 µs | — | 占 stage 的 2.8% |
**锁仲裁 0.76µs 不可与实验 3 的 19.40µs 对照**——测的不是同一个东西:
实验 3 测插件经 RPC 请求锁的完整跨进程往返,本基准只测内核侧
`lockRegistry.acquire/release`。真实成本仍在 20µs 量级。基准原名
`BenchmarkStageLockRoundTrip` 有误导性,已改为 `BenchmarkStageLockArbitration`。
**stage 往返 132µs 的成本构成**:共享段编解码只占 3.7µs其余是
**一次 stage 要走 3 次进程间往返**`stage.invoke` + 插件侧反向的
`stage.lock` / `stage.unlock`)。相对 LLM 往返 2-8 秒可忽略;
要优化的方向是把 lock/unlock 合入 `stage.invoke` 的请求/应答。
### 流式压测§4.3 标记「风险高」的那一项)
```
5000 次 Publish + 每条睡 20µs 的慢消费者
实测 2.29ms,均摊 457 ns/token
同步语义理论下限 100ms
订阅者 1 个1.547ms515 ns/次)
订阅者 8 个1.518ms506 ns/次) ← 无线性恶化
环溢出(无消费者写 30000 次cap=8192均摊 35 ns/次 ← 仍 O(1)
```
2.29ms 与实验 4 的数字完全一致(那次也是 2.29ms / 0.46µs per token
post-and-forget 在实现中成立。第三项的意义:消费者完全停摆时写端覆盖
最旧 slot这条路径仍是 O(1),故「插件卡住」不会连带拖慢内核主循环。
---
## 版本号
v1.0.0tag 已打)。公开 SDK 接口零改动,但产物形态从 `plugin.so` 变为
`plugin.bin`0.9.x 内核不会识别——不可互操作的破坏性变化,故跃主版本号。
⚠️ **Makefile 陷阱**`VERSION ?= $(shell git describe --tags --dirty)`
意味着实际注入值来自 git tag`meta.go` 里的默认值只在不带 ldflags 时生效。
打 tag 前 `make build` 注入的是 `v0.9.1-56-g2572688-dirty`。
同时删掉 C ABI 时代的死常量(`ABIVersion`/`CABINum`/51 个 `Core<Method>`
整数 ID——随 Part 6.2 删 `internal/plugin/cabi/` 就已无使用者,
留着会让人以为 C 层协商还在生效,或以为加 method 要同步维护那张整数表。

File diff suppressed because it is too large Load Diff

View File

@ -67,6 +67,17 @@ func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
if err != nil {
return fmt.Sprintf("通过 [%s] 通道发送失败: %v", channel, err)
}
// 通道可能回报「未确认」(已提交但超时未拿到发送确认)——此时不能对模型
// 谎报「已发送」,否则模型不会重试/核实plan.md 11.1)。
if m, ok := result.(map[string]interface{}); ok {
if status, _ := m["status"].(string); status == "unconfirmed" || status == "queued" {
note, _ := m["note"].(string)
if note == "" {
note = "发送已提交但未收到通道确认,结果未知"
}
return fmt.Sprintf("[%s] 通道发送结果未确认:%s", channel, note)
}
}
return fmt.Sprintf("已通过 [%s] 通道发送: %v", channel, result)
}

View File

@ -14,14 +14,25 @@ type StageHost struct {
toolDefs []sdk.ToolDef
tools map[string]sdk.ToolHandler
toolPlugins map[string]string
stages map[sdk.Stage][]sdk.StageHandler
stages map[sdk.Stage][]stageEntry
}
// stageEntry 把 stage handler 与它的归属插件绑定。
//
// 为何需要归属:子进程插件崩溃后,它注册的 handler 闭包仍在这张表里,
// 每次 RunStage 都会经 RPC 打向已死进程并报 ErrProcessExited重启后新 handler
// 又追加进来,旧的永不退场——错误与重复执行随重启次数线性累积。
// 有了归属才能在卸载/崩溃时成组摘除。
type stageEntry struct {
plugin string
fn sdk.StageHandler
}
func NewStageHost() *StageHost {
return &StageHost{
tools: make(map[string]sdk.ToolHandler),
toolPlugins: make(map[string]string),
stages: make(map[sdk.Stage][]sdk.StageHandler),
stages: make(map[sdk.Stage][]stageEntry),
}
}
@ -41,9 +52,15 @@ func (h *StageHost) RegisterTool(name string, def sdk.ToolDef, handler sdk.ToolH
}
func (h *StageHost) RegisterStage(stage sdk.Stage, handler sdk.StageHandler) {
h.RegisterStageFor("", stage, handler)
}
// RegisterStageFor 注册带归属插件名的 stage handler。
// plugin 为空时等同 RegisterStage内核自身注册的 handler不参与成组摘除
func (h *StageHost) RegisterStageFor(plugin string, stage sdk.Stage, handler sdk.StageHandler) {
h.mu.Lock()
defer h.mu.Unlock()
h.stages[stage] = append(h.stages[stage], handler)
h.stages[stage] = append(h.stages[stage], stageEntry{plugin: plugin, fn: handler})
}
func (h *StageHost) GetToolDefs() []sdk.ToolDef {
@ -106,6 +123,36 @@ func (h *StageHost) UnregisterPluginTools(pluginName string) {
h.toolDefs = keepDefs
}
// UnregisterPluginStages 摘除某插件注册的全部 stage handler返回摘除数量。
//
// 与 UnregisterPluginTools 成对:卸载/重载/崩溃时两者都得做,
// 否则插件的工具没了但 stage handler 还在,继续打向不存在的插件。
func (h *StageHost) UnregisterPluginStages(pluginName string) int {
if pluginName == "" {
return 0
}
h.mu.Lock()
defer h.mu.Unlock()
removed := 0
for stage, entries := range h.stages {
keep := entries[:0:0]
for _, e := range entries {
if e.plugin == pluginName {
removed++
continue
}
keep = append(keep, e)
}
if len(keep) == 0 {
delete(h.stages, stage)
continue
}
h.stages[stage] = keep
}
return removed
}
func inferToolPlugin(name string) string {
for i := 0; i < len(name); i++ {
if name[i] == '_' {
@ -123,14 +170,15 @@ func inferToolPlugin(name string) string {
// handler 返回的 error 会被收集到 ctx.Errors 中并记录日志,不会中断其他 handler 的执行。
func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
h.mu.RLock()
handlers := h.stages[stage]
entries := make([]stageEntry, len(h.stages[stage]))
copy(entries, h.stages[stage])
h.mu.RUnlock()
if len(handlers) == 0 {
if len(entries) == 0 {
return
}
var wg sync.WaitGroup
errCh := make(chan error, len(handlers))
for _, handler := range handlers {
errCh := make(chan error, len(entries))
for _, entry := range entries {
wg.Add(1)
go func(fn sdk.StageHandler) {
defer wg.Done()
@ -142,7 +190,7 @@ func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
if err := fn(ctx); err != nil {
errCh <- err
}
}(handler)
}(entry.fn)
}
wg.Wait()
close(errCh)

View File

@ -0,0 +1,96 @@
package core
import (
"testing"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// stage handler 必须能按插件成组摘除。
//
// 修复前 StageHost.stages 只存匿名函数,没有归属信息:
// 插件崩溃/卸载后它的 handler 永远留在表里,每轮 RunStage 都被并发调起并
// 打向已死进程;重启后新 handler 追加进来,旧的仍不退场——
// 错误与重复执行随重启次数线性累积。
func TestUnregisterPluginStages_RemovesOnlyThatPlugin(t *testing.T) {
h := NewStageHost()
var aRan, bRan, coreRan int
h.RegisterStageFor("a", sdk.StagePreAction, func(*sdk.StageContext) error { aRan++; return nil })
h.RegisterStageFor("b", sdk.StagePreAction, func(*sdk.StageContext) error { bRan++; return nil })
// 内核自身注册的 handler无归属不该被插件摘除波及
h.RegisterStage(sdk.StagePreAction, func(*sdk.StageContext) error { coreRan++; return nil })
h.RunStage(sdk.StagePreAction, &sdk.StageContext{})
if aRan != 1 || bRan != 1 || coreRan != 1 {
t.Fatalf("首轮应全部执行a=%d b=%d core=%d", aRan, bRan, coreRan)
}
if n := h.UnregisterPluginStages("a"); n != 1 {
t.Errorf("应摘除 1 个 handler实际 %d", n)
}
h.RunStage(sdk.StagePreAction, &sdk.StageContext{})
if aRan != 1 {
t.Errorf("已摘除的插件 handler 不该再被调用,实际执行 %d 次", aRan)
}
if bRan != 2 || coreRan != 2 {
t.Errorf("其他 handler 应照常执行b=%d core=%d", bRan, coreRan)
}
}
// 摘除某插件的最后一个 handler 后,该 stage 应从表中消失RunStage 直接短路)。
func TestUnregisterPluginStages_DropsEmptyStage(t *testing.T) {
h := NewStageHost()
h.RegisterStageFor("solo", sdk.StageAfterToolcall, func(*sdk.StageContext) error { return nil })
if n := h.UnregisterPluginStages("solo"); n != 1 {
t.Fatalf("应摘除 1 个,实际 %d", n)
}
h.mu.RLock()
_, exists := h.stages[sdk.StageAfterToolcall]
h.mu.RUnlock()
if exists {
t.Error("stage 已无 handler 时应从表中删除")
}
}
// 空插件名不得误摘内核自身注册的 handler。
func TestUnregisterPluginStages_EmptyNameIsNoop(t *testing.T) {
h := NewStageHost()
ran := 0
h.RegisterStage(sdk.StagePreAction, func(*sdk.StageContext) error { ran++; return nil })
if n := h.UnregisterPluginStages(""); n != 0 {
t.Errorf("空插件名应是 no-op实际摘除 %d", n)
}
h.RunStage(sdk.StagePreAction, &sdk.StageContext{})
if ran != 1 {
t.Errorf("内核 handler 应保留并执行,实际 %d 次", ran)
}
}
// 工具与 stage 的摘除互不干扰:都摘完后两者皆空。
func TestUnregisterPluginToolsAndStages_Together(t *testing.T) {
h := NewStageHost()
if err := h.RegisterTool("demo_run", sdk.ToolDef{Name: "demo_run", Plugin: "demo"},
func(map[string]interface{}) (interface{}, error) { return nil, nil }); err != nil {
t.Fatal(err)
}
h.RegisterStageFor("demo", sdk.StagePreAction, func(*sdk.StageContext) error { return nil })
h.UnregisterPluginTools("demo")
h.UnregisterPluginStages("demo")
if h.ToolCount() != 0 {
t.Errorf("工具应已摘除,实际 %d", h.ToolCount())
}
if h.ToolPlugin("demo_run") != "" {
t.Error("工具→插件映射应清空")
}
// 摘除后可重新注册同名工具(重启路径的前提)
if err := h.RegisterTool("demo_run", sdk.ToolDef{Name: "demo_run", Plugin: "demo"},
func(map[string]interface{}) (interface{}, error) { return nil, nil }); err != nil {
t.Errorf("摘除后应可重新注册同名工具,实际: %v", err)
}
}

View File

@ -1,13 +1,16 @@
// Package meta 收集 HomeAgent 内核的全部元数据。
// 版本号通过 `go build -ldflags` 注入,默认值为 dev 版本。
// 此文件是 ABI 版本号与 dispatch method ID 的唯一数据源。
// SDK 仓的 meta/meta.go 应与此保持同步。
package meta
var (
// Version 是 HomeAgent 内核版本号。
// 通过 `-ldflags="-X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Version=vX.Y.Z"` 注入。
Version = "0.9.1"
//
// 1.0.0:外部插件从 C ABI 动态库迁到子进程 + 共享内存。
// 这是首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件必须
// 用新版 plugindev 重编),故跃到主版本号。
Version = "1.0.0"
// Commit 是构建时的 Git commit hash。
Commit = "unknown"
@ -19,7 +22,7 @@ var (
KernelName = "HomeAgent"
// SDKCompatibleVersion 是此内核可兼容的最高 SDK 版本semver
SDKCompatibleVersion = "0.9.1"
SDKCompatibleVersion = "1.0.0"
)
// FullVersion 返回完整的版本字符串。
@ -27,81 +30,15 @@ func FullVersion() string {
return KernelName + " v" + Version + " (" + Commit + ")"
}
// ---- ABI 版本C ABI 协议版本,插件与内核通信用) ----
// ABI 版本直接取内核版本号字符串semver与核心 Version 保持一致,不再使用独立数字编码。
// 协商层C 结构体 int version 字段)使用 CABINum由版本字符串派生的整数major*100 + minor
// 映射v0.8.x → CABINum=800v0.9.x → CABINum=900invoke_stage 写回)。
// 小版本patch演进不影响 ABICABINum 不变。version_min 保证旧 ABI 插件仍可加载
var (
// ABIVersion 是 ABI 标识版本(字符串 semver与核心 Version 对齐)。
ABIVersion = Version
// ABIVersionMin 是兼容的最低 ABI 标识版本。
ABIVersionMin = "0.8.0"
)
const (
// CABINum 是 C 层协商用的整数版本major*100 + minor随 ABIVersion 派生。
CABINum = 900
// CABINumMin 是 C 层兼容的最低整数版本。
// 旧工具链v0.8 之前)写入的整数 version=1无写回能力但与新内核结构兼容
// 因此最小值保持 1 以兼容全部旧插件(新插件 900 匹配,旧插件 1/2 通过);
// 仅当未来内核 ABI 破坏兼容时才提高该值。
CABINumMin = 1
)
// ---- Dispatch Method IDs ----
// 核心→插件:这些 ID 通过 CoreAPI.dispatch 传递,标识 SDK 调用。
// 插件端的 C enum 定义在 plugindev 的 C ABI header 模板中。
const (
CoreRegisterTool = 1
CoreRegisterStage = 2
CoreRegisterOutputCh = 3
CoreRegisterPluginAPI = 4
CoreInjectText = 5
CoreInjectInterruptText = 6
CoreInjectTextNoMemory = 7
CoreSetAutoRestart = 8
CoreMemoryRecall = 9
CoreMemoryCommit = 10
CoreMemoryIntrospect = 11
CoreMemoryMerge = 12
CoreMemoryPurge = 13
CoreDocQuery = 14
CoreKnowledgeSearch = 15
CoreSettingsGet = 16
CoreSettingsSet = 17
CoreSettingsRegisterDef = 18
CoreLLMListSources = 19
CoreLLMSetSource = 20
CoreSocialGetPerson = 21
CoreSocialGetNetwork = 22
CoreSubscribe = 23
CoreUnsubscribe = 24
CoreFreeString = 25
CoreSettingsGetCore = 26
CoreSettingsSetCore = 27
CoreSettingsListCore = 28
CoreSettingsGetPlugin = 29
CoreSettingsSetPlugin = 30
CoreSettingsListPlugin = 31
CoreDocInsert = 32
CoreDocRemove = 33
CoreDocStats = 34
CoreKnowledgeAdd = 35
CoreKnowledgeList = 36
CoreLLMCurrentSource = 37
CoreSocialGetTrait = 38
CoreSocialGetRelations = 39
CoreSocialListPersons = 40
CoreTextMemoryAppend = 41
CoreSettingsList = 42
CoreSettingsDefs = 43
CoreSettingsDump = 44
CoreSettingsPlugins = 45
CoreRegisterInputCh = 46
CoreInjectInputSync = 47
CorePluginReloadOne = 48
CorePluginListLoaded = 49
CorePluginIsDisabled = 50
)
// ---- 协议版本 ----
//
// 子进程 RPC 的协议版本是一个独立的小整数,与内核语义版本解耦:
// 语义版本变动频繁(修 bug、加字段而 wire 协议只在**帧格式或握手语义**
// 变化时才升。当前值见 internal/plugin/proc/protocol.go 的 ProtocolVersion
//
// C ABI 时代的 ABIVersion / CABINum / 51 个 Core<Method> 整数 ID 已随
// Part 6.2 删除 internal/plugin/cabi/ 一并退场:
// - 整数 method id 平移为 method 名字符串proc/protocol.go 的 Method* 常量)
// - 版本协商改为握手帧里的 protocol 字段
//
// 保留那些常量只会让人以为它们还在生效。

View File

@ -1,262 +0,0 @@
//go:build windows
package plugin
import (
"encoding/json"
"os"
"path/filepath"
"syscall"
"testing"
"unsafe"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func TestBridgeE2E_WebPlugin(t *testing.T) {
exeDir, _ := os.Executable()
// Find web example build relative to the homeagent repo root
haRoot := findHomeAgentRoot(t, exeDir)
dllPath := filepath.Join(haRoot, "..", "homeagentsdk", "example", "web", "build", "plugin.dll")
if _, err := os.Stat(dllPath); os.IsNotExist(err) {
t.Fatalf("web plugin DLL not found at %s\nRun: cd example/web && plugindev build --target windows/amd64", dllPath)
}
// Track captured tools and stages
var capturedTools []sdk.ToolDef
var capturedStages []sdk.Stage
regTool := func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
capturedTools = append(capturedTools, def)
t.Logf(" registered tool: %s", name)
return nil
}
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
capturedStages = append(capturedStages, stage)
t.Logf(" registered stage: %s", stage)
}
regAPI := func(name string) error {
t.Logf(" registered API: %s", name)
return nil
}
sett := sdk.NewSettings("web", nil)
psdk := sdk.New("web", sdk.SDKConfig{Settings: sett, RegTool: regTool, RegStage: regStage, RegAPI: regAPI})
plg, err := newDLLPlugin(dllPath, "web", nil)
if err != nil {
t.Fatalf("newDLLPlugin failed: %v", err)
}
defer plg.Stop()
// Start — this calls NewPlugin + StartPlugin + registerTools + registerStages
if err := plg.Start(psdk); err != nil {
t.Fatalf("Start failed: %v", err)
}
// Verify tools were captured
if len(capturedTools) == 0 {
t.Fatal("no tools were registered by web plugin")
}
t.Logf("Captured %d tools:", len(capturedTools))
for _, d := range capturedTools {
t.Logf(" - %s: %s", d.Name, d.Description[:min(len(d.Description), 60)])
}
// Check specific expected tools
webSearch, webFetch := false, false
for _, d := range capturedTools {
if d.Name == "web_search" {
webSearch = true
if d.Description == "" {
t.Error("web_search has empty description")
}
params := d.Parameters
if params == nil {
t.Error("web_search has nil parameters")
} else {
if _, ok := params["properties"]; !ok {
t.Error("web_search parameters missing 'properties'")
}
}
}
if d.Name == "web_fetch" {
webFetch = true
}
}
if !webSearch {
t.Error("expected tool 'web_search' not registered")
}
if !webFetch {
t.Error("expected tool 'web_fetch' not registered")
}
// Verify bridge exports work via direct C ABI calls
t.Logf("Bridge exports: getTools=%x invokeTool=%x freeCStr=%x",
plg.getTools, plg.invokeTool, plg.freeCStr)
// GetToolDefsJSON
if plg.getTools != 0 {
toolDefsJSON := callGetToolDefsJSON(t, plg)
if len(toolDefsJSON) == 0 {
t.Error("GetToolDefsJSON returned empty array, expected tools")
}
for _, d := range toolDefsJSON {
t.Logf(" bridge tool: %s", d["name"])
}
}
// InvokeToolJSON — test with the search tool
if plg.invokeTool != 0 {
result := callInvokeToolJSON(t, plg, "web_search", map[string]interface{}{
"query": "test",
"count": 1,
})
t.Logf("InvokeToolJSON result keys: %v", keysOfMap(result))
// Should get a result map (might be error if no network, but should not crash)
if errStr, ok := result["error"]; ok {
t.Logf(" (expected — tool returned error: %v)", errStr)
}
}
}
func TestBridgeE2E_SanitizerStages(t *testing.T) {
exeDir, _ := os.Executable()
haRoot := findHomeAgentRoot(t, exeDir)
dllPath := filepath.Join(haRoot, "..", "homeagentsdk", "example", "sanitizer", "build", "plugin.dll")
if _, err := os.Stat(dllPath); os.IsNotExist(err) {
t.Skip("sanitizer DLL not built")
}
var capturedStages []sdk.Stage
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
capturedStages = append(capturedStages, stage)
t.Logf(" registered stage: %s", stage)
}
sett := sdk.NewSettings("sanitizer", nil)
psdk := sdk.New("sanitizer", sdk.SDKConfig{Settings: sett,
RegTool: func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { return nil },
RegStage: regStage,
RegAPI: func(name string) error { return nil },
})
plg, err := newDLLPlugin(dllPath, "sanitizer", nil)
if err != nil {
t.Fatalf("newDLLPlugin failed: %v", err)
}
defer plg.Stop()
if err := plg.Start(psdk); err != nil {
t.Fatalf("Start failed: %v", err)
}
if len(capturedStages) == 0 {
t.Fatal("no stages registered by sanitizer")
}
found := false
for _, s := range capturedStages {
if s == sdk.StagePostAction {
found = true
}
}
if !found {
t.Fatalf("expected post_action stage, got %v", capturedStages)
}
// Verify bridge GetStagesJSON
if plg.getStages != 0 {
ret, _, _ := syscall.SyscallN(plg.getStages, plg.handle)
if ret != 0 {
stagesJSON := cStringPtrToString(ret)
if plg.freeCStr != 0 {
syscall.SyscallN(plg.freeCStr, ret)
}
t.Logf("GetStagesJSON: %s", stagesJSON)
if !contains(t, stagesJSON, "post_action") {
t.Error("GetStagesJSON missing post_action")
}
}
}
}
// --- helpers ---
func findHomeAgentRoot(t *testing.T, exeDir string) string {
t.Helper()
// Walk up from test binary directory looking for homeagent/
dir := exeDir
for i := 0; i < 10; i++ {
if _, err := os.Stat(filepath.Join(dir, "internal", "plugin")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
t.Fatal("cannot find homeagent root")
return ""
}
func callGetToolDefsJSON(t *testing.T, plg *dllPlugin) []map[string]interface{} {
t.Helper()
ret, _, _ := syscall.SyscallN(plg.getTools, plg.handle)
if ret == 0 {
t.Fatal("GetToolDefsJSON returned nil")
}
jsonStr := cStringPtrToString(ret)
if plg.freeCStr != 0 {
syscall.SyscallN(plg.freeCStr, ret)
}
var defs []map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &defs); err != nil {
t.Fatalf("GetToolDefsJSON parse error: %v", err)
}
return defs
}
func callInvokeToolJSON(t *testing.T, plg *dllPlugin, toolName string, args map[string]interface{}) map[string]interface{} {
t.Helper()
argsJSON, _ := json.Marshal(args)
cToolName := append([]byte(toolName), 0)
cArgs := append(argsJSON, 0)
ret, _, _ := syscall.SyscallN(
plg.invokeTool,
plg.handle,
uintptr(unsafe.Pointer(&cToolName[0])),
uintptr(unsafe.Pointer(&cArgs[0])),
)
if ret == 0 {
t.Fatal("InvokeToolJSON returned nil")
}
jsonStr := cStringPtrToString(ret)
if plg.freeCStr != 0 {
syscall.SyscallN(plg.freeCStr, ret)
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &result); err != nil {
t.Fatalf("InvokeToolJSON parse error: %v (json=%s)", err, jsonStr)
}
return result
}
func keysOfMap(m map[string]interface{}) []string {
var keys []string
for k := range m {
keys = append(keys, k)
}
return keys
}
func contains(t *testing.T, s, substr string) bool {
t.Helper()
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@ -1,79 +0,0 @@
//go:build linux || darwin
// HomeAgent C ABI loader — C implementation (compiled alongside Go code via cgo)
#include <dlfcn.h>
#include <stdlib.h>
#include <string.h>
// HOMEAGENT_ABI_VERSION 与 internal/meta/meta.go CABINum 同步major*100+minorv0.9.x→900
// C ABI 通过 version/version_min 协商,旧插件不受影响。
#define HOMEAGENT_ABI_VERSION 900
// PluginAPI — provided by the plugin
typedef struct {
int version; int version_min;
int (*init_plugin)(char*, char*, char**);
int (*start_plugin)(void*, int, char**);
int (*stop_plugin)(char**);
int (*invoke_tool)(char*, char*, char**, char**);
int (*invoke_stage)(char*, char*, char**, char**);
int (*invoke_output)(char*, char*, char*, char**);
void (*free_string)(char*);
} plugin_api_t;
// CoreAPI — provided by the core
typedef struct {
int version; int version_min;
int (*dispatch)(int, void*, char*, char*, char*, int, int, char**, char**);
void* ctx;
} core_api_t;
// Forward declare Go dispatch function
extern int go_core_dispatch(int, void*, char*, char*, char*, int, int, char**, char**);
// Bridge function called by CoreAPI.dispatch
static int dispatch_bridge(int id, void* ctx, char* s1, char* s2, char* s3, int i1, int i2, char** r, char** e) {
return go_core_dispatch(id, ctx, s1, s2, s3, i1, i2, r, e);
}
// Create a CoreAPI struct
core_api_t* make_core_api(void) {
core_api_t* api = (core_api_t*)malloc(sizeof(core_api_t));
if (!api) return NULL;
api->version = HOMEAGENT_ABI_VERSION;
api->version_min = HOMEAGENT_ABI_VERSION;
api->dispatch = dispatch_bridge;
api->ctx = NULL;
return api;
}
void free_core_api(core_api_t* api) { free(api); }
// dlopen helpers
typedef void* lib_handle;
lib_handle lib_open(const char* path) {
return dlopen(path, RTLD_NOW | RTLD_LOCAL);
}
plugin_api_t* lib_get_api(lib_handle h) {
plugin_api_t* (*fn)(void);
*(void**)(&fn) = dlsym(h, "plugin_init");
if (!fn) return NULL;
return fn();
}
void lib_close(lib_handle h) { dlclose(h); }
char* lib_err(void) { return dlerror(); }
void api_free_string(plugin_api_t* api, char* ptr) {
if (api && api->free_string) api->free_string(ptr);
}
int call_init_plugin(plugin_api_t* api, char* name, char* config, char** err) { return api->init_plugin(name, config, err); }
int call_start_plugin(plugin_api_t* api, void* core, int ver, char** err) { return api->start_plugin(core, ver, err); }
int call_stop_plugin(plugin_api_t* api, char** err) { return api->stop_plugin(err); }
int call_invoke_tool(plugin_api_t* api, char* n, char* a, char** r, char** e) { return api->invoke_tool(n, a, r, e); }
int call_invoke_stage(plugin_api_t* api, char* s, char* c, char** r, char** e) { return api->invoke_stage(s, c, r, e); }
int call_invoke_output(plugin_api_t* api, char* c, char* m, char* p, char** e) { return api->invoke_output(c, m, p, e); }

View File

@ -1,951 +0,0 @@
//go:build linux || darwin
package cabi
/*
#cgo LDFLAGS: -ldl
#include <stdlib.h>
// HOMEAGENT_ABI_VERSION 是当前内核的 C ABI 整数协商版本,由 internal/meta/meta.go CABINum 派生
// major*100 + minor随核心版本号映射v0.8.x→800v0.9.x→900
// 旧插件使用低整数版本不受影响——C ABI wrapper 通过 version/version_min 字段协商兼容。
#define HOMEAGENT_ABI_VERSION 900
// PluginAPI — provided by the plugin via plugin_init()
typedef struct {
int version; int version_min;
int (*init_plugin)(char*, char*, char**);
int (*start_plugin)(void*, int, char**);
int (*stop_plugin)(char**);
int (*invoke_tool)(char*, char*, char**, char**);
int (*invoke_stage)(char*, char*, char**, char**);
int (*invoke_output)(char*, char*, char*, char**);
void (*free_string)(char*);
} plugin_api_t;
// CoreAPI — provided by the core via start_plugin()
typedef struct {
int version; int version_min;
int (*dispatch)(int, void*, char*, char*, char*, int, int, char**, char**);
void* ctx;
} core_api_t;
// Functions implemented in loader.c
extern core_api_t* make_core_api(void);
extern void free_core_api(core_api_t* api);
extern int call_init_plugin(plugin_api_t*, char*, char*, char**);
extern int call_start_plugin(plugin_api_t*, void*, int, char**);
extern int call_stop_plugin(plugin_api_t*, char**);
extern int call_invoke_tool(plugin_api_t*, char*, char*, char**, char**);
extern int call_invoke_stage(plugin_api_t*, char*, char*, char**, char**);
extern int call_invoke_output(plugin_api_t*, char*, char*, char*, char**);
extern void api_free_string(plugin_api_t*, char*);
extern void* lib_open(const char*);
extern plugin_api_t* lib_get_api(void*);
extern void lib_close(void*);
extern char* lib_err(void);
*/
import "C"
import (
"encoding/json"
"fmt"
"log"
"sync"
"sync/atomic"
"unsafe"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
var (
pluginMap sync.Map // int32 pluginID → *pluginState
nextID int32
)
type pluginState struct {
id int32
name string
sdk *sdk.PluginSDK
api *C.plugin_api_t
}
// Handle represents a loaded C ABI plugin.
type Handle struct {
soPath string
lib unsafe.Pointer
api *C.plugin_api_t
core *C.core_api_t
pstate *pluginState
}
// Load opens a .so plugin and initializes it via C ABI.
func Load(soPath, name string, config map[string]interface{}) (*Handle, error) {
cPath := C.CString(soPath)
defer C.free(unsafe.Pointer(cPath))
lib := C.lib_open(cPath)
if lib == nil {
return nil, fmt.Errorf("dlopen %s: %s", soPath, C.GoString(C.lib_err()))
}
api := C.lib_get_api(lib)
if api == nil {
C.lib_close(lib)
return nil, fmt.Errorf("dlsym plugin_init in %s: %s", soPath, C.GoString(C.lib_err()))
}
if int(api.version) < 1 || api.init_plugin == nil {
C.lib_close(lib)
return nil, fmt.Errorf("plugin %s: invalid PluginAPI (version=%d)", name, int(api.version))
}
if int(api.version) > CABINum {
C.lib_close(lib)
return nil, fmt.Errorf("plugin %s: ABI version %d > core %d (v%s), requires newer HomeAgent core", name, int(api.version), CABINum, ABIVersion)
}
if int(api.version) < CABINumMin {
C.lib_close(lib)
return nil, fmt.Errorf("plugin %s: ABI version %d < core min %d (v%s), plugin too old", name, int(api.version), CABINumMin, ABIVersionMin)
}
id := atomic.AddInt32(&nextID, 1)
ps := &pluginState{id: id, name: name, api: api}
pluginMap.Store(id, ps)
handle := &Handle{soPath: soPath, lib: lib, api: api, pstate: ps}
// Create CoreAPI later — done via CreateCoreAPI
// Initialize plugin
configJSON, _ := json.Marshal(config)
cName := C.CString(name)
cConfig := C.CString(string(configJSON))
var initErr *C.char
defer C.free(unsafe.Pointer(cName))
defer C.free(unsafe.Pointer(cConfig))
if ret := int(C.call_init_plugin(api, cName, cConfig, &initErr)); ret != 0 {
errMsg := ""
if initErr != nil {
errMsg = C.GoString(initErr)
C.api_free_string(api, initErr)
}
handle.Close()
return nil, fmt.Errorf("init_plugin %s: %s", name, errMsg)
}
return handle, nil
}
// CreateCoreAPI creates a CoreAPI struct for this plugin.
// The CoreAPI dispatches all SDK calls back to Go, routing to the plugin's PluginSDK.
func (h *Handle) CreateCoreAPI(s *sdk.PluginSDK) unsafe.Pointer {
core := C.make_core_api()
if core == nil {
return nil
}
h.core = core
h.pstate.sdk = s
// Store plugin ID as context (safe integer, not a Go pointer)
core.ctx = unsafe.Pointer(uintptr(h.pstate.id))
return unsafe.Pointer(core)
}
// FreeCoreAPI frees the CoreAPI struct.
func (h *Handle) FreeCoreAPI() {
if h.core != nil {
C.free_core_api(h.core)
h.core = nil
}
}
// Start calls the plugin's Start with a CoreAPI pointer.
func (h *Handle) Start(corePtr unsafe.Pointer) error {
var cErr *C.char
if ret := int(C.call_start_plugin(h.api, corePtr, C.int(CABINum), &cErr)); ret != 0 {
errMsg := ""
if cErr != nil {
errMsg = C.GoString(cErr)
C.api_free_string(h.api, cErr)
}
return fmt.Errorf("start_plugin: %s", errMsg)
}
return nil
}
// Stop calls the plugin's Stop.
func (h *Handle) Stop() error {
var cErr *C.char
if ret := int(C.call_stop_plugin(h.api, &cErr)); ret != 0 {
errMsg := ""
if cErr != nil {
errMsg = C.GoString(cErr)
C.api_free_string(h.api, cErr)
}
return fmt.Errorf("stop_plugin: %s", errMsg)
}
return nil
}
// InvokeTool calls a tool handler in the plugin.
func (h *Handle) InvokeTool(name string, args map[string]interface{}) (map[string]interface{}, error) {
argsJSON, _ := json.Marshal(args)
cName := C.CString(name)
cArgs := C.CString(string(argsJSON))
var result, cErr *C.char
defer C.free(unsafe.Pointer(cName))
defer C.free(unsafe.Pointer(cArgs))
if ret := int(C.call_invoke_tool(h.api, cName, cArgs, &result, &cErr)); ret != 0 {
errMsg := ""
if cErr != nil {
errMsg = C.GoString(cErr)
C.api_free_string(h.api, cErr)
}
return nil, fmt.Errorf("invoke_tool %s: %s", name, errMsg)
}
if result == nil {
return nil, nil
}
defer C.api_free_string(h.api, result)
var r map[string]interface{}
if err := json.Unmarshal([]byte(C.GoString(result)), &r); err != nil {
return nil, err
}
return r, nil
}
// Close unloads the plugin library.
func (h *Handle) Close() {
if h.lib != nil {
C.lib_close(h.lib)
h.lib = nil
}
}
// ---- plugin invocation helpers (stateless, use pluginMap lookup) ----
func pluginInvokeTool(pluginID int32, name, argsJSON string) (string, error) {
v, ok := pluginMap.Load(pluginID)
if !ok {
return "", fmt.Errorf("plugin %d not found", pluginID)
}
ps := v.(*pluginState)
if ps.api == nil {
return "", fmt.Errorf("plugin %d: nil api", pluginID)
}
cName := C.CString(name)
cArgs := C.CString(argsJSON)
var result, cErr *C.char
defer C.free(unsafe.Pointer(cName))
defer C.free(unsafe.Pointer(cArgs))
if ret := int(C.call_invoke_tool(ps.api, cName, cArgs, &result, &cErr)); ret != 0 {
errMsg := ""
if cErr != nil {
errMsg = C.GoString(cErr)
C.api_free_string(ps.api, cErr)
}
return "", fmt.Errorf("invoke_tool %s: %s", name, errMsg)
}
if result == nil {
return "", nil
}
defer C.api_free_string(ps.api, result)
return C.GoString(result), nil
}
func pluginInvokeOutput(pluginID int32, channel, payload string) error {
v, ok := pluginMap.Load(pluginID)
if !ok {
return fmt.Errorf("plugin %d not found", pluginID)
}
ps := v.(*pluginState)
if ps.api == nil {
return fmt.Errorf("plugin %d: nil api", pluginID)
}
cCh := C.CString(channel)
cPayload := C.CString(payload)
var cErr *C.char
defer C.free(unsafe.Pointer(cCh))
defer C.free(unsafe.Pointer(cPayload))
if ret := int(C.call_invoke_output(ps.api, cCh, nil, cPayload, &cErr)); ret != 0 {
errMsg := ""
if cErr != nil {
errMsg = C.GoString(cErr)
C.api_free_string(ps.api, cErr)
}
return fmt.Errorf("invoke_output %s: %s", channel, errMsg)
}
return nil
}
// applyStageResult 将插件回传的修改后上下文应用回内核 StageContext。
// 只回写插件有权改写的字段RawMessage/LLMText/FinalText/Response/ToolResults/NoMemory
func applyStageResult(sc *sdk.StageContext, resultJSON string) {
var m map[string]interface{}
if err := json.Unmarshal([]byte(resultJSON), &m); err != nil {
return
}
sc.Lock()
defer sc.Unlock()
if v, ok := m["raw_message"].(string); ok {
sc.RawMessage = v
}
if v, ok := m["llm_text"].(string); ok {
sc.LLMText = v
}
if v, ok := m["final_text"].(string); ok {
sc.FinalText = v
}
if v, ok := m["user_id"].(string); ok {
sc.UserID = v
}
if v, ok := m["group_id"].(string); ok {
sc.GroupID = v
}
if v, ok := m["no_memory"].(bool); ok {
sc.NoMemory = v
}
if v, ok := m["response"].(string); ok {
vv := v
sc.Response = &vv
}
if v, ok := m["tool_calls"].([]interface{}); ok && len(v) > 0 {
if b, err := json.Marshal(v); err == nil {
var tcs []sdk.ToolCall
if json.Unmarshal(b, &tcs) == nil {
sc.ToolCalls = tcs
}
}
}
if v, ok := m["tool_results"].([]interface{}); ok && len(v) > 0 {
if b, err := json.Marshal(v); err == nil {
var trs []sdk.ToolResult
if json.Unmarshal(b, &trs) == nil {
sc.ToolResults = trs
}
}
}
}
func pluginInvokeStage(pluginID int32, stage, ctxJSON string, resultOut *string) error {
v, ok := pluginMap.Load(pluginID)
if !ok {
return fmt.Errorf("plugin %d not found", pluginID)
}
ps := v.(*pluginState)
if ps.api == nil {
return fmt.Errorf("plugin %d: nil api", pluginID)
}
cStage := C.CString(stage)
cCtx := C.CString(ctxJSON)
var cErr *C.char
var cResult *C.char
defer C.free(unsafe.Pointer(cStage))
defer C.free(unsafe.Pointer(cCtx))
// 仅当调用方要求回传时传 &cResult否则传 NULL兼容无需写回的阶段
if ret := int(C.call_invoke_stage(ps.api, cStage, cCtx, &cResult, &cErr)); ret != 0 {
errMsg := ""
if cErr != nil {
errMsg = C.GoString(cErr)
C.api_free_string(ps.api, cErr)
}
return fmt.Errorf("invoke_stage %s: %s", stage, errMsg)
}
if resultOut != nil && cResult != nil {
*resultOut = C.GoString(cResult)
C.api_free_string(ps.api, cResult)
}
return nil
}
// go_core_dispatch handles all plugin→core SDK calls.
//
//export go_core_dispatch
func go_core_dispatch(methodID C.int, ctx unsafe.Pointer, s1, s2, s3 *C.char, i1, i2 C.int, result **C.char, errorOut **C.char) C.int {
pluginID := int32(uintptr(ctx))
v, ok := pluginMap.Load(pluginID)
if !ok {
return 1
}
ps := v.(*pluginState)
s := ps.sdk
if s == nil {
return 1
}
a1, a2, a3 := goStr(s1), goStr(s2), goStr(s3)
n1, n2 := int(i1), int(i2)
switch int(methodID) {
case 1: // CORE_REGISTER_TOOL
var def sdk.ToolDef
if err := json.Unmarshal([]byte(a2), &def); err != nil {
setErr(errorOut, err)
return 1
}
def.Plugin = ps.name
pid := pluginID
toolName := a1
_ = s.RegisterTool(a1, def, func(args map[string]interface{}) (interface{}, error) {
argsJSON, _ := json.Marshal(args)
r, err := pluginInvokeTool(pid, toolName, string(argsJSON))
if err != nil {
return nil, err
}
if r == "" {
return nil, nil
}
var res map[string]interface{}
if err := json.Unmarshal([]byte(r), &res); err != nil {
return r, nil
}
return res, nil
})
return 0
case 2: // CORE_REGISTER_STAGE
pid := pluginID
st := a1
handler := func(sc *sdk.StageContext) error {
sc.RLock()
m := map[string]interface{}{
"raw_message": sc.RawMessage, "user_id": sc.UserID,
"group_id": sc.GroupID, "phase": string(sc.Phase),
"llm_text": sc.LLMText, "final_text": sc.FinalText,
"no_memory": sc.NoMemory,
}
if sc.Response != nil {
m["response"] = *sc.Response
}
if len(sc.ToolCalls) > 0 {
m["tool_calls"] = sc.ToolCalls
}
if len(sc.ToolResults) > 0 {
m["tool_results"] = sc.ToolResults
}
sc.RUnlock()
b, _ := json.Marshal(m)
// ABI v2: 插件可回传修改后的上下文写回内核 sc如 RawMessage/LLMText/Response/ToolResults
var result string
if err := pluginInvokeStage(pid, st, string(b), &result); err != nil {
return err
}
if result != "" {
applyStageResult(sc, result)
}
return nil
}
scope := sdk.StageScopeGlobal
if a3 == "own_tools" {
scope = sdk.StageScopeOwnTools
}
s.RegisterStage(sdk.Stage(st), handler, scope)
return 0
case 3: // CORE_REGISTER_OUTPUT_CH
pid := pluginID
chName := a1
chDef := sdk.ChannelDef{}
if a3 != "" {
var def sdk.ChannelDef
if err := json.Unmarshal([]byte(a3), &def); err == nil {
chDef = def
}
}
s.RegisterOutputChannel(chName, n1, a2, chDef, func(args map[string]interface{}) (interface{}, error) {
// Output is async: return immediately, send in background
// to avoid nested cgo calls (cgo within cgo can crash)
go func() {
argsJSON, _ := json.Marshal(args)
log.Printf("[dispatch] async output %s/%s args=%s", ps.name, chName, string(argsJSON))
if err := pluginInvokeOutput(pid, chName, string(argsJSON)); err != nil {
log.Printf("[dispatch] async output %s/%s failed: %v", ps.name, chName, err)
} else {
log.Printf("[dispatch] async output %s/%s OK", ps.name, chName)
}
}()
return map[string]interface{}{"status": "queued"}, nil
})
return 0
case 4: // CORE_REGISTER_PLUGIN_API
s.RegisterPluginAPI(a1)
return 0
case 5: // CORE_INJECT_TEXT
s.InjectText(a1, a2, a3)
return 0
case 6: // CORE_INJECT_INTERRUPT_TEXT
s.InjectInterruptText(a1, a2, a3)
return 0
case 7: // CORE_INJECT_TEXT_NO_MEMORY
s.InjectTextNoMemory(a1, a2, a3)
return 0
case 47: // CORE_INJECT_INPUT_SYNC
if out := s.InjectInputSync(a1, a2, "text", map[string]interface{}{"content": a3}); out != nil {
reply, _ := out.Payload["content"].(string)
setResult(result, reply)
}
return 0
case 8: // CORE_SET_AUTO_RESTART
s.SetAutoRestart(n1 != 0)
return 0
case 9: // CORE_MEMORY_RECALL
if mem := s.Memory(); mem != nil {
entities, relations, err := mem.Recall([]string{a1}, n1)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(map[string]interface{}{"entities": entities, "relations": relations})
setResult(result, string(b))
}
return 0
case 10: // CORE_MEMORY_COMMIT
if mem := s.Memory(); mem != nil {
var triples []sdk.Triple
if err := json.Unmarshal([]byte(a1), &triples); err != nil {
setErr(errorOut, err)
return 1
}
if err := mem.Commit(triples); err != nil {
setErr(errorOut, err)
return 1
}
}
return 0
case 11: // CORE_MEMORY_INTROSPECT
if mem := s.Memory(); mem != nil {
r, err := mem.Introspect()
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(r)
setResult(result, string(b))
}
return 0
case 12: // CORE_MEMORY_MERGE
if mem := s.Memory(); mem != nil {
if _, err := mem.MergeEntities(a1, a2); err != nil {
setErr(errorOut, err)
return 1
}
}
return 0
case 13: // CORE_MEMORY_PURGE
if mem := s.Memory(); mem != nil {
var criteria map[string]string
if err := json.Unmarshal([]byte(a1), &criteria); err != nil {
setErr(errorOut, err)
return 1
}
mode := "soft"
if n1 != 0 {
mode = "hard"
}
if _, err := mem.Purge(criteria, mode); err != nil {
setErr(errorOut, err)
return 1
}
}
return 0
case 14: // CORE_DOC_QUERY
if dm := s.DocMemory(); dm != nil {
b, _ := json.Marshal(dm.Query(a1, n1))
setResult(result, string(b))
}
return 0
case 15: // CORE_KNOWLEDGE_SEARCH
if kn := s.Knowledge(); kn != nil {
results, err := kn.Search(a1, n1)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(results)
setResult(result, string(b))
}
return 0
case 16: // CORE_SETTINGS_GET
if sett := s.Settings(); sett != nil {
v, err := sett.Get(a1)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(v)
setResult(result, string(b))
}
return 0
case 17: // CORE_SETTINGS_SET
if sett := s.Settings(); sett != nil {
var v interface{}
json.Unmarshal([]byte(a2), &v)
if err := sett.Set(a1, v); err != nil {
setErr(errorOut, err)
return 1
}
}
return 0
case 18: // CORE_SETTINGS_REGISTER_DEF
if sett := s.Settings(); sett != nil {
var def sdk.ConfigDef
if err := json.Unmarshal([]byte(a1), &def); err != nil {
setErr(errorOut, err)
return 1
}
sett.RegisterDef(def)
}
return 0
case 19: // CORE_LLM_LIST_SOURCES
if llm := s.LLM(); llm != nil {
b, _ := json.Marshal(llm.ListSources())
setResult(result, string(b))
}
return 0
case 20: // CORE_LLM_SET_SOURCE
if llm := s.LLM(); llm != nil {
if err := llm.SetSource(a1); err != nil {
setErr(errorOut, err)
return 1
}
}
return 0
case 21: // CORE_SOCIAL_GET_PERSON
if social := s.Social(); social != nil {
p, err := social.GetPerson(a1)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(p)
setResult(result, string(b))
}
return 0
case 22: // CORE_SOCIAL_GET_NETWORK
if social := s.Social(); social != nil {
profiles, err := social.GetNetwork(a1, n1)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(profiles)
setResult(result, string(b))
}
return 0
case 23: // CORE_SUBSCRIBE
_ = n2
// Events API not wired for external plugins (SetEventSubscriber not called)
return 0
case 24: // CORE_UNSUBSCRIBE
return 0
case 25: // CORE_FREE_STRING
if s1 != nil {
C.free(unsafe.Pointer(s1))
}
return 0
case 26: // CORE_SETTINGS_GET_CORE
if sett := s.Settings(); sett != nil {
v, err := sett.GetCore(a1)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(v)
setResult(result, string(b))
}
return 0
case 27: // CORE_SETTINGS_SET_CORE
if sett := s.Settings(); sett != nil {
var v interface{}
json.Unmarshal([]byte(a2), &v)
if err := sett.SetCore(a1, v); err != nil {
setErr(errorOut, err)
return 1
}
}
return 0
case 28: // CORE_SETTINGS_LIST_CORE
if sett := s.Settings(); sett != nil {
keys, err := sett.ListCore(a1)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(keys)
setResult(result, string(b))
}
return 0
case 29: // CORE_SETTINGS_GET_PLUGIN
if sett := s.Settings(); sett != nil {
v, err := sett.GetPlugin(a1, a2)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(v)
setResult(result, string(b))
}
return 0
case 30: // CORE_SETTINGS_SET_PLUGIN
if sett := s.Settings(); sett != nil {
var v interface{}
json.Unmarshal([]byte(a3), &v)
if err := sett.SetPlugin(a1, a2, v); err != nil {
setErr(errorOut, err)
return 1
}
}
return 0
case 31: // CORE_SETTINGS_LIST_PLUGIN
if sett := s.Settings(); sett != nil {
keys, err := sett.ListPlugin(a1, a2)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(keys)
setResult(result, string(b))
}
return 0
case 32: // CORE_DOC_INSERT
if dm := s.DocMemory(); dm != nil {
var doc sdk.Doc
if err := json.Unmarshal([]byte(a1), &doc); err != nil {
setErr(errorOut, err)
return 1
}
if err := dm.Insert(&doc); err != nil {
setErr(errorOut, err)
return 1
}
}
return 0
case 33: // CORE_DOC_REMOVE
if dm := s.DocMemory(); dm != nil {
dm.Remove(a1)
}
return 0
case 34: // CORE_DOC_STATS
if dm := s.DocMemory(); dm != nil {
b, _ := json.Marshal(dm.Stats())
setResult(result, string(b))
}
return 0
case 35: // CORE_KNOWLEDGE_ADD
if kn := s.Knowledge(); kn != nil {
if err := kn.Add(a1, a2); err != nil {
setErr(errorOut, err)
return 1
}
}
return 0
case 36: // CORE_KNOWLEDGE_LIST
if kn := s.Knowledge(); kn != nil {
list, err := kn.List()
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(list)
setResult(result, string(b))
}
return 0
case 37: // CORE_LLM_CURRENT_SOURCE
if llm := s.LLM(); llm != nil {
b, _ := json.Marshal(llm.CurrentSource())
setResult(result, string(b))
}
return 0
case 38: // CORE_SOCIAL_GET_TRAIT
if social := s.Social(); social != nil {
val, ok := social.GetTrait(a1, a2)
b, _ := json.Marshal(map[string]interface{}{"value": val, "found": ok})
setResult(result, string(b))
}
return 0
case 39: // CORE_SOCIAL_GET_RELATIONS
if social := s.Social(); social != nil {
rels, err := social.GetRelations(a1)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(rels)
setResult(result, string(b))
}
return 0
case 40: // CORE_SOCIAL_LIST_PERSONS
if social := s.Social(); social != nil {
persons, err := social.ListPersons()
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(persons)
setResult(result, string(b))
}
return 0
case 41: // CORE_TEXT_MEMORY_APPEND
if tm := s.TextMemory(); tm != nil {
var evt sdk.TextEvent
if err := json.Unmarshal([]byte(a1), &evt); err != nil {
setErr(errorOut, err)
return 1
}
if err := tm.Append(evt); err != nil {
setErr(errorOut, err)
return 1
}
}
return 0
case 42: // CORE_SETTINGS_LIST
if sett := s.Settings(); sett != nil {
keys, err := sett.List(a1)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(keys)
setResult(result, string(b))
}
return 0
case 43: // CORE_SETTINGS_DEFS
if sett := s.Settings(); sett != nil {
defs := sett.Defs(a1)
b, _ := json.Marshal(defs)
setResult(result, string(b))
}
return 0
case 44: // CORE_SETTINGS_DUMP
if sett := s.Settings(); sett != nil {
dump := sett.Dump()
b, _ := json.Marshal(dump)
setResult(result, string(b))
}
return 0
case 45: // CORE_SETTINGS_PLUGINS
if sett := s.Settings(); sett != nil {
plugins := sett.Plugins()
b, _ := json.Marshal(plugins)
setResult(result, string(b))
}
return 0
case 51: // CORE_SETTINGS_DATA_DIR插件专属数据目录内核保证存在
if sett := s.Settings(); sett != nil {
setResult(result, sett.DataDir())
}
return 0
case 46: // CORE_REGISTER_INPUT_CH
chDef := sdk.ChannelDef{}
if a2 != "" {
var def sdk.ChannelDef
if err := json.Unmarshal([]byte(a2), &def); err == nil {
chDef = def
}
}
s.RegisterInputChannel(a1, chDef)
return 0
case 48: // CORE_PLUGIN_RELOAD_ONE
if s.PluginMgr() == nil {
setErr(errorOut, fmt.Errorf("plugin manager not available"))
return 1
}
if err := s.PluginMgr().ReloadOne(a1); err != nil {
setErr(errorOut, err)
return 1
}
setResult(result, "reloaded: "+a1)
return 0
case 49: // CORE_PLUGIN_LIST_LOADED
if s.PluginMgr() == nil {
setErr(errorOut, fmt.Errorf("plugin manager not available"))
return 1
}
if b, err := json.Marshal(s.PluginMgr().ListLoadedPlugins()); err == nil {
setResult(result, string(b))
}
return 0
case 50: // CORE_PLUGIN_IS_DISABLED
if s.PluginMgr() == nil {
setErr(errorOut, fmt.Errorf("plugin manager not available"))
return 1
}
if s.PluginMgr().IsPluginDisabled(a1) {
setResult(result, "1")
} else {
setResult(result, "0")
}
return 0
}
return 0
}
func goStr(s *C.char) string {
if s == nil {
return ""
}
return C.GoString(s)
}
func setErr(errOut **C.char, err error) {
if errOut != nil && err != nil {
*errOut = C.CString(err.Error())
}
}
func setResult(result **C.char, v string) {
if result != nil {
*result = C.CString(v)
}
}

View File

@ -1,66 +0,0 @@
package cabi
import "gitcode.com/JianFeeeee/HomeAgent/internal/meta"
// ABI version constants — single source of truth is meta.go
// ABIVersion/ABIVersionMin 是字符串 semvervar 转发,因 meta 侧 Version 为注入变量);
// CABINum/CABINumMin 是 C 层整数协商版本。
var (
ABIVersion = meta.ABIVersion
ABIVersionMin = meta.ABIVersionMin
)
const (
CABINum = meta.CABINum
CABINumMin = meta.CABINumMin
)
// Dispatch method IDs — single source of truth is meta.go
const (
CoreRegisterTool = meta.CoreRegisterTool
CoreRegisterStage = meta.CoreRegisterStage
CoreRegisterOutputCh = meta.CoreRegisterOutputCh
CoreRegisterPluginAPI = meta.CoreRegisterPluginAPI
CoreInjectText = meta.CoreInjectText
CoreInjectInterruptText = meta.CoreInjectInterruptText
CoreInjectTextNoMemory = meta.CoreInjectTextNoMemory
CoreSetAutoRestart = meta.CoreSetAutoRestart
CoreMemoryRecall = meta.CoreMemoryRecall
CoreMemoryCommit = meta.CoreMemoryCommit
CoreMemoryIntrospect = meta.CoreMemoryIntrospect
CoreMemoryMerge = meta.CoreMemoryMerge
CoreMemoryPurge = meta.CoreMemoryPurge
CoreDocQuery = meta.CoreDocQuery
CoreKnowledgeSearch = meta.CoreKnowledgeSearch
CoreSettingsGet = meta.CoreSettingsGet
CoreSettingsSet = meta.CoreSettingsSet
CoreSettingsRegisterDef = meta.CoreSettingsRegisterDef
CoreLLMListSources = meta.CoreLLMListSources
CoreLLMSetSource = meta.CoreLLMSetSource
CoreSocialGetPerson = meta.CoreSocialGetPerson
CoreSocialGetNetwork = meta.CoreSocialGetNetwork
CoreSubscribe = meta.CoreSubscribe
CoreUnsubscribe = meta.CoreUnsubscribe
CoreFreeString = meta.CoreFreeString
CoreSettingsGetCore = meta.CoreSettingsGetCore
CoreSettingsSetCore = meta.CoreSettingsSetCore
CoreSettingsListCore = meta.CoreSettingsListCore
CoreSettingsGetPlugin = meta.CoreSettingsGetPlugin
CoreSettingsSetPlugin = meta.CoreSettingsSetPlugin
CoreSettingsListPlugin = meta.CoreSettingsListPlugin
CoreDocInsert = meta.CoreDocInsert
CoreDocRemove = meta.CoreDocRemove
CoreDocStats = meta.CoreDocStats
CoreKnowledgeAdd = meta.CoreKnowledgeAdd
CoreKnowledgeList = meta.CoreKnowledgeList
CoreLLMCurrentSource = meta.CoreLLMCurrentSource
CoreSocialGetTrait = meta.CoreSocialGetTrait
CoreSocialGetRelations = meta.CoreSocialGetRelations
CoreSocialListPersons = meta.CoreSocialListPersons
CoreTextMemoryAppend = meta.CoreTextMemoryAppend
CoreSettingsList = meta.CoreSettingsList
CoreSettingsDefs = meta.CoreSettingsDefs
CoreSettingsDump = meta.CoreSettingsDump
CoreSettingsPlugins = meta.CoreSettingsPlugins
CoreRegisterInputCh = meta.CoreRegisterInputCh
)

View File

@ -0,0 +1,249 @@
package plugin
import (
"errors"
"os"
"path/filepath"
"sync"
"testing"
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// fakeCleaner 记录内核侧摘除动作,用于断言「插件死后注册面被摘干净」。
//
// 同时实现 PluginToolCleaner 与 PluginStageCleaner——生产里 StageHost 两者都实现。
type fakeCleaner struct {
mu sync.Mutex
tools []string
stages []string
}
func (f *fakeCleaner) UnregisterPluginTools(name string) {
f.mu.Lock()
defer f.mu.Unlock()
f.tools = append(f.tools, name)
}
func (f *fakeCleaner) UnregisterPluginStages(name string) int {
f.mu.Lock()
defer f.mu.Unlock()
f.stages = append(f.stages, name)
return 1
}
func (f *fakeCleaner) toolCalls() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.tools...)
}
func (f *fakeCleaner) stageCalls() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.stages...)
}
func contains(list []string, want string) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}
// newTestRegistry 造一个可用于 detach/崩溃路径测试的最小 Registry。
func newTestRegistry(t *testing.T) (*Registry, *fakeCleaner, *agentIO.IOManager) {
t.Helper()
cleaner := &fakeCleaner{}
iom := agentIO.NewIOManager()
r := &Registry{
plugins: make(map[string]sdk.Plugin),
factories: make(map[string]NativeFactory),
pluginAutoRestart: make(map[string]bool),
sdkRefs: make(map[string]*sdk.PluginSDK),
knownDisabled: make(map[string]bool),
pluginHashes: make(map[string]string),
pluginChannels: make(map[string]*pluginChannelSet),
plgDir: t.TempDir(),
toolCleaner: cleaner,
iom: iom,
}
return r, cleaner, iom
}
// detachPlugin 必须同时摘工具、stage handler、IO 通道。
//
// 此前各卸载路径只调 UnregisterPluginTools漏了后两项
// 插件的工具没了但 stage handler 还在每轮 RunStage 里被调用并失败,
// output device 还留在 IOManager 里让模型看到一个永远发不出去的通道。
func TestDetachPlugin_RemovesToolsStagesAndChannels(t *testing.T) {
r, cleaner, iom := newTestRegistry(t)
// 模拟插件注册过通道
if err := iom.RegisterDevice(&channelDevice{name: "demo_out"}); err != nil {
t.Fatalf("RegisterDevice: %v", err)
}
iom.RegisterInputChannel("demo_in", agentIO.ChannelDef{})
r.noteChannel("demo", "demo_out", true)
r.noteChannel("demo", "demo_in", false)
r.detachPlugin("demo")
if !contains(cleaner.toolCalls(), "demo") {
t.Error("应摘除插件工具")
}
if !contains(cleaner.stageCalls(), "demo") {
t.Error("应摘除插件 stage handler否则每轮 RunStage 都会打向已死插件)")
}
if iom.GetDevice("demo_out") != nil {
t.Error("output device 应被摘除,否则模型仍看到一个必然失败的通道")
}
if _, ok := iom.GetInputChannelDef("demo_in"); ok {
t.Error("input channel 定义应被摘除")
}
}
// 通道台账在 detach 后清空,使插件重启时能重新注册同名通道。
//
// 不清空的后果RegisterDevice 撞上同名旧 device 直接报 already registered
// 新进程的通道注册不上——插件“重启成功”了但通道永久指向已死进程。
func TestReleasePluginChannels_AllowsReRegistrationAfterRestart(t *testing.T) {
r, _, iom := newTestRegistry(t)
if err := iom.RegisterDevice(&channelDevice{name: "qq"}); err != nil {
t.Fatalf("首次注册: %v", err)
}
r.noteChannel("qq", "qq", true)
r.detachPlugin("qq")
// 重启后同名通道必须能重新注册
if err := iom.RegisterDevice(&channelDevice{name: "qq"}); err != nil {
t.Fatalf("摘除后应可重新注册同名通道,实际: %v", err)
}
// 台账已清空,重复 detach 不应再摘掉新注册的那个
r.releasePluginChannels("qq")
if iom.GetDevice("qq") == nil {
t.Error("台账已清空,重复 detach 不应摘掉重启后新注册的通道")
}
}
// 崩溃回调必须摘注册面 + 排重启,且不阻塞调用方(它跑在 readLoop 的 goroutine 里)。
func TestOnProcCrash_DetachesImmediately(t *testing.T) {
r, cleaner, _ := newTestRegistry(t)
// 没有插件目录 → ReloadOne 必然失败,但摘除动作应已完成
r.pluginAutoRestart["ghost"] = false // 关掉自动重启,只验摘除
done := make(chan struct{})
go func() {
r.onProcCrash("ghost", errors.New("signal: killed"))
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("onProcCrash 不应阻塞(它在 readLoop 的 goroutine 上)")
}
if !contains(cleaner.toolCalls(), "ghost") {
t.Error("崩溃后应立即摘除工具,否则模型继续调用一个必然失败的工具")
}
if !contains(cleaner.stageCalls(), "ghost") {
t.Error("崩溃后应摘除 stage handler")
}
}
// 声明了不自动重启的插件,崩溃后不得被拉起。
func TestScheduleProcRestart_RespectsAutoRestartOff(t *testing.T) {
r, _, _ := newTestRegistry(t)
r.pluginAutoRestart["noauto"] = false
r.scheduleProcRestart("noauto", errors.New("boom"))
if n := r.crashCount("noauto"); n != 0 {
t.Errorf("禁用自动重启时不该记崩溃计数,实际 %d", n)
}
}
// 窗口内连续崩溃超过上限后停止自动重启,避免崩溃循环打满 CPU。
func TestScheduleProcRestart_StopsAfterThreshold(t *testing.T) {
r, _, _ := newTestRegistry(t)
for i := 0; i < procMaxRestarts+2; i++ {
r.noteCrash("loopy")
}
if got := r.crashCount("loopy"); got != procMaxRestarts+2 {
t.Fatalf("崩溃计数应累计,实际 %d", got)
}
// 超阈值后再调不应尝试重启(无插件目录时重启必然失败并留日志,
// 这里只验它提前返回:计数不再增长)。
before := r.crashCount("loopy")
r.scheduleProcRestart("loopy", errors.New("again"))
if after := r.crashCount("loopy"); after != before+1 {
t.Errorf("应只记一次计数即返回before=%d after=%d", before, after)
}
}
// 崩溃计数在窗口外自动归零,避免偶发崩溃永久累积成“不可重启”。
func TestNoteCrash_WindowExpiry(t *testing.T) {
r, _, _ := newTestRegistry(t)
r.noteCrash("old")
r.crashMu.Lock()
r.procCrashes["old"].last = time.Now().Add(-procCrashWindow - time.Second)
r.crashMu.Unlock()
if got := r.crashCount("old"); got != 0 {
t.Errorf("窗口外计数应归零,实际 %d", got)
}
if got := r.noteCrash("old"); got != 1 {
t.Errorf("窗口外应重新从 1 计,实际 %d", got)
}
}
// 关停途中不得再拉起插件:段已拆而进程还在会直接 SIGBUS。
func TestScheduleProcRestart_SkippedDuringShutdown(t *testing.T) {
r, _, _ := newTestRegistry(t)
r.shuttingDown.Store(true)
r.scheduleProcRestart("any", errors.New("boom"))
if n := r.crashCount("any"); n != 0 {
t.Errorf("关停中应直接返回,不记计数,实际 %d", n)
}
}
// PluginRuntime 对未安装插件返回 false对有目录的插件报告加载通道。
func TestPluginRuntime_ReportsChannelAndInstallState(t *testing.T) {
r, _, _ := newTestRegistry(t)
if _, ok := r.PluginRuntime("nope"); ok {
t.Error("未安装插件应返回 false")
}
dir := filepath.Join(r.plgDir, "procplug")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
bin := filepath.Join(dir, binEntry)
if err := os.WriteFile(bin, []byte("#!/bin/true\n"), 0o755); err != nil {
t.Fatal(err)
}
info, ok := r.PluginRuntime("procplug")
if !ok {
t.Fatal("有插件目录应视为已安装")
}
if info.Channel != "proc" {
t.Errorf("应识别为 proc 通道,实际 %q", info.Channel)
}
if info.Loaded || info.Alive {
t.Error("未加载的插件不应报告 loaded/alive")
}
}

View File

@ -7,12 +7,98 @@ import (
)
const (
soEntry = "plugin.so"
dllEntry = "plugin.dll"
luaEntry = "main.lua"
metaEntry = "plugin.json"
binEntry = "plugin.bin" // 子进程插件(纯 Go 二进制stdio JSON-RPC + 共享内存)
luaEntry = "main.lua"
skillEntry = "SKILL.md"
metaEntry = "plugin.json"
)
// legacyCABIEntries 是已退场的 C ABI 产物名。
//
// 保留这张表只为**给出明确错误**:插件目录里躺着 plugin.so 而内核不再认它时,
// 静默跳过会让「目录在但插件没加载」看起来像配置问题,而实际原因是需要用
// 新版 plugindev 重编。
var legacyCABIEntries = []string{"plugin.so", "plugin.dll", "plugin.dylib"}
// entryKind 描述插件入口归属的加载通道。
//
// C ABI 通道(.so/.dll/.dylib已整体退场外部插件统一走子进程 + stdio RPC
// 三套独立 ABI 实现收敛为单一 RPC 实现§9.2)。
type entryKind int
const (
entryUnknown entryKind = iota
entryProc // plugin.bin —— 子进程 + stdio JSON-RPC + 共享内存
entryLua // main.lua
entrySkill // SKILL.md
)
func (k entryKind) String() string {
switch k {
case entryProc:
return "proc"
case entryLua:
return "lua"
case entrySkill:
return "skill"
}
return "unknown"
}
// classifyEntry 把 manifest 的 entry 字段映射到加载通道。
//
// entry 为空或声明已退场的 C ABI 产物时返回 entryUnknown
// 由调用方回退到目录探测(兼容无 manifest 的旧插件),
// 并在探测到 C ABI 残留时给出明确的重编提示。
func classifyEntry(entry string) entryKind {
switch entry {
case binEntry:
return entryProc
case luaEntry:
return entryLua
case skillEntry:
return entrySkill
}
return entryUnknown
}
// detectEntryKind 先读 manifest 的 entry读不到则按目录内存在的入口文件推断。
//
// 注意:存量插件的 plugin.json 可能仍写着 "plugin.so"(工具链已不再据此分派,
// 但历史产物里有),此时 classifyEntry 返回 unknown靠目录探测找到 plugin.bin。
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},
{luaEntry, entryLua},
{skillEntry, entrySkill},
} {
if st, err := os.Stat(filepath.Join(plgDir, probe.file)); err == nil && !st.IsDir() {
return probe.kind
}
}
return entryUnknown
}
// hasLegacyCABIEntry 判断插件目录里是否只剩已退场的 C ABI 产物。
//
// 用于给出「需要重编」而非「插件不存在」的错误。
func hasLegacyCABIEntry(plgDir string) bool {
for _, name := range legacyCABIEntries {
if st, err := os.Stat(filepath.Join(plgDir, name)); err == nil && !st.IsDir() {
return true
}
}
return false
}
func readManifest(dir string) *PluginManifest {
data, err := os.ReadFile(filepath.Join(dir, metaEntry))
if err != nil {
@ -24,5 +110,3 @@ func readManifest(dir string) *PluginManifest {
}
return &m
}
var _ = json.Marshal

View File

@ -1,11 +0,0 @@
//go:build !windows
package plugin
import (
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func tryLoadDLL(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
return nil, nil
}

View File

@ -1,32 +0,0 @@
//go:build windows
package plugin
import (
"os"
"path/filepath"
"testing"
)
func TestTryLoadDLL_NoFile(t *testing.T) {
dir := t.TempDir()
plg, err := tryLoadDLL(dir, "nonexistent", nil)
if err != nil {
t.Fatalf("tryLoadDLL on empty dir should not error: %v", err)
}
if plg != nil {
t.Fatal("expected nil for non-existent plugin.dll")
}
}
func TestTryLoadDLL_Invalid(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "plugin.dll"), []byte("not a real dll"), 0644)
plg, err := tryLoadDLL(dir, "baddll", nil)
t.Logf("plg=%v err=%v", plg, err)
if err == nil && plg == nil {
t.Fatal("expected error or non-nil plugin for existing file")
}
}

View File

@ -1,272 +0,0 @@
//go:build windows
package plugin
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"syscall"
"unsafe"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// dllPlugin wraps a Windows DLL compiled with -buildmode=c-shared.
//
// Required exports:
//
// NewPlugin(name *C.char, configJSON *C.char) unsafe.Pointer → plugin handle
// StartPlugin(handle unsafe.Pointer) C.int
// StopPlugin(handle unsafe.Pointer) C.int
// DestroyPlugin(handle unsafe.Pointer)
//
// Optional exports (tool registration):
//
// GetToolDefsJSON(handle unsafe.Pointer) *C.char → JSON array of tool defs
// InvokeToolJSON(handle unsafe.Pointer, toolName *C.char, argsJSON *C.char) *C.char
// FreeCString(s *C.char) → free C string from DLL
// GetStagesJSON(handle unsafe.Pointer) *C.char → JSON array of stage names
// InvokeStage(handle unsafe.Pointer, stage *C.char, contextJSON *C.char) C.int
type dllPlugin struct {
name string
dll syscall.Handle
handle uintptr
sdk *sdk.PluginSDK
// cached proc addresses
newPlugin uintptr
startPlugin uintptr
stopPlugin uintptr
destroyPlugin uintptr
getTools uintptr
invokeTool uintptr
freeCStr uintptr
getStages uintptr
invokeStage uintptr
}
func findProc(dll syscall.Handle, name string) uintptr {
addr, err := syscall.GetProcAddress(dll, name)
if err != nil {
return 0
}
return addr
}
func newDLLPlugin(dllPath, name string, config map[string]interface{}) (*dllPlugin, error) {
dll, err := syscall.LoadLibrary(dllPath)
if err != nil {
return nil, fmt.Errorf("LoadLibrary %s: %w", dllPath, err)
}
np := findProc(dll, "NewPlugin")
if np == 0 {
_ = syscall.FreeLibrary(dll)
return nil, fmt.Errorf("dll %s must export NewPlugin", name)
}
return &dllPlugin{
name: name,
dll: dll,
// required
newPlugin: np,
startPlugin: findProc(dll, "StartPlugin"),
stopPlugin: findProc(dll, "StopPlugin"),
destroyPlugin: findProc(dll, "DestroyPlugin"),
// optional tool/stage API
getTools: findProc(dll, "GetToolDefsJSON"),
invokeTool: findProc(dll, "InvokeToolJSON"),
freeCStr: findProc(dll, "FreeCString"),
getStages: findProc(dll, "GetStagesJSON"),
invokeStage: findProc(dll, "InvokeStage"),
}, nil
}
func (p *dllPlugin) Name() string { return p.name }
func (p *dllPlugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
cfgJSON, _ := json.Marshal(map[string]interface{}{
"name": p.name,
"config": s.Settings().Dump(),
})
cName := append([]byte(p.name), 0)
cConfig := append(cfgJSON, 0)
ret, _, _ := syscall.SyscallN(
p.newPlugin,
uintptr(unsafe.Pointer(&cName[0])),
uintptr(unsafe.Pointer(&cConfig[0])),
)
if ret == 0 {
_ = syscall.FreeLibrary(p.dll)
return fmt.Errorf("dll NewPlugin %s returned nil", p.name)
}
p.handle = ret
if p.startPlugin != 0 {
syscall.SyscallN(p.startPlugin, p.handle)
}
// discover and register tools from DLL
if p.getTools != 0 {
if err := p.registerTools(s); err != nil {
return fmt.Errorf("dll %s register tools: %w", p.name, err)
}
}
if p.getStages != 0 {
p.registerStages(s)
}
return nil
}
func (p *dllPlugin) Stop() error {
if p.stopPlugin != 0 {
syscall.SyscallN(p.stopPlugin, p.handle)
}
if p.destroyPlugin != 0 {
syscall.SyscallN(p.destroyPlugin, p.handle)
}
_ = syscall.FreeLibrary(p.dll)
return nil
}
// --- tool registration via C ABI ---
type dllToolDef struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters,omitempty"`
}
func (p *dllPlugin) registerTools(s *sdk.PluginSDK) error {
ret, _, _ := syscall.SyscallN(p.getTools, p.handle)
if ret == 0 {
return nil // no tools
}
defsJSON := cStringPtrToString(ret)
if p.freeCStr != 0 {
syscall.SyscallN(p.freeCStr, ret)
}
var defs []dllToolDef
if err := json.Unmarshal([]byte(defsJSON), &defs); err != nil {
return fmt.Errorf("parse tool defs: %w", err)
}
for _, d := range defs {
if d.Name == "" {
continue
}
toolName := d.Name
handler := p.makeToolHandler(toolName)
s.RegisterTool(toolName, sdk.ToolDef{
Name: toolName,
Description: d.Description,
Parameters: d.Parameters,
Plugin: p.name,
}, handler)
}
return nil
}
func (p *dllPlugin) makeToolHandler(toolName string) sdk.ToolHandler {
return func(args map[string]interface{}) (interface{}, error) {
if p.invokeTool == 0 {
return nil, fmt.Errorf("dll %s does not export InvokeToolJSON", p.name)
}
argsJSON, _ := json.Marshal(args)
cToolName := append([]byte(toolName), 0)
cArgs := append(argsJSON, 0)
ret, _, _ := syscall.SyscallN(
p.invokeTool,
p.handle,
uintptr(unsafe.Pointer(&cToolName[0])),
uintptr(unsafe.Pointer(&cArgs[0])),
)
if ret == 0 {
return nil, fmt.Errorf("dll InvokeToolJSON %s returned nil", toolName)
}
resultJSON := cStringPtrToString(ret)
if p.freeCStr != 0 {
syscall.SyscallN(p.freeCStr, ret)
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(resultJSON), &result); err != nil {
return nil, fmt.Errorf("dll tool %s result parse: %w", toolName, err)
}
return result, nil
}
}
func (p *dllPlugin) registerStages(s *sdk.PluginSDK) {
ret, _, _ := syscall.SyscallN(p.getStages, p.handle)
if ret == 0 {
return
}
stagesJSON := cStringPtrToString(ret)
if p.freeCStr != 0 {
syscall.SyscallN(p.freeCStr, ret)
}
type stageEntry struct {
Stage string `json:"stage"`
}
var entries []stageEntry
if err := json.Unmarshal([]byte(stagesJSON), &entries); err != nil {
return
}
for _, e := range entries {
if e.Stage == "" {
continue
}
stageName := sdk.Stage(e.Stage)
stage := stageName
s.RegisterStage(stage, func(sc *sdk.StageContext) error {
if p.invokeStage == 0 {
return nil
}
ctxJSON, _ := json.Marshal(map[string]interface{}{
"raw_message": sc.RawMessage,
"user_id": sc.UserID,
"phase": string(sc.Phase),
})
cStage := append([]byte(stage), 0)
cCtx := append(ctxJSON, 0)
syscall.SyscallN(
p.invokeStage,
p.handle,
uintptr(unsafe.Pointer(&cStage[0])),
uintptr(unsafe.Pointer(&cCtx[0])),
)
return nil
})
}
}
func cStringPtrToString(ptr uintptr) string {
if ptr == 0 {
return ""
}
var buf []byte
for i := uintptr(0); ; i++ {
b := *(*byte)(unsafe.Pointer(ptr + i))
if b == 0 {
break
}
buf = append(buf, b)
}
return string(buf)
}
// tryLoadDLL 尝试从插件目录加载 plugin.dll。
// 返回 nil,nil 表示目录中没有 plugin.dll。
func tryLoadDLL(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
dllPath := filepath.Join(dir, dllEntry)
if _, err := os.Stat(dllPath); os.IsNotExist(err) {
return nil, nil
}
return newDLLPlugin(dllPath, name, config)
}

View File

@ -1,79 +0,0 @@
//go:build linux || darwin
package plugin
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin/cabi"
)
type dynamicPlugin struct {
name string
impl pubsdk.Plugin
}
func (p *dynamicPlugin) Name() string { return p.name }
func (p *dynamicPlugin) Start(s *sdk.PluginSDK) error {
return p.impl.Start(s.PluginSDK)
}
func (p *dynamicPlugin) Stop() error { return p.impl.Stop() }
type cabiPlugin struct {
name string
handle *cabi.Handle
}
func (p *cabiPlugin) Name() string { return p.name }
func (p *cabiPlugin) Start(s *sdk.PluginSDK) error {
corePtr := p.handle.CreateCoreAPI(s)
if corePtr == nil {
return fmt.Errorf("cabi: failed to create CoreAPI for %s", p.name)
}
if err := p.handle.Start(corePtr); err != nil {
return fmt.Errorf("cabi: start %s: %w", p.name, err)
}
return nil
}
func (p *cabiPlugin) Stop() error {
_ = p.handle.Stop()
return nil
}
// Close 卸载动态库dlclose。卸载/重载后必须调用,否则同一路径的 dlopen
// 会复用旧句柄Linux dlopen 语义),新版本的 plugin.so 不会生效。
func (p *cabiPlugin) Close() error {
p.handle.Close()
return nil
}
func tryLoadSO(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
soPath := filepath.Join(dir, soEntry)
if _, err := os.Stat(soPath); os.IsNotExist(err) {
// 回退尝试 plugin.dylib (macOS 原生扩展名)
dylibPath := filepath.Join(dir, "plugin.dylib")
if _, err2 := os.Stat(dylibPath); err2 == nil {
soPath = dylibPath
} else {
return nil, nil
}
}
handle, err := cabi.Load(soPath, name, config)
if err == nil {
return &cabiPlugin{name: name, handle: handle}, nil
}
// 本项目插件统一由 plugindev 编译为 c-shared 走 C ABI
// 对 c-shared .so 调用 Go plugin.Open 会 fatalno plugin module data
// 因此不再 fallback 到 Go plugin直接返回加载错误避免崩溃。
return nil, err
}
var _ = json.Marshal

View File

@ -1,11 +0,0 @@
//go:build windows
package plugin
import (
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func tryLoadSO(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
return nil, nil
}

View File

@ -0,0 +1,254 @@
//go:build linux || darwin
package plugin
import (
"fmt"
"log"
"os"
"path/filepath"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin/proc"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// tryLoadProc 只做静态校验(供双通道探测与测试),不构造插件实体。
//
// 真正加载走 Registry.loadProc子进程插件需要共享段 Host
// 而 Host 必须是**全部 .bin 插件共用的那一个**,只能由 Registry 持有。
//
// 返回 nil,nil 表示目录中没有 plugin.bin交由后续探测通道
// 找到二进制但不可用时返回明确错误——不静默回退到 cabi。
func tryLoadProc(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
_, err := validateProcBinary(dir, name)
return nil, err
}
// 子进程插件加载plugin.bin——外部插件多进程化的加载入口。
//
// 设计依据docs/zh/架构迁移评估.md §3stdio JSON-RPC 控制面 + shm 数据面 + eventfd 通知面)
// 实施计划docs/zh/plugin-migration-plan.md Part 2/3
// validateProcBinary 校验 plugin.bin 是否存在且可执行。
// 返回 ("", nil) 表示该目录不是 proc 插件。
func validateProcBinary(dir, name string) (string, error) {
binPath := filepath.Join(dir, binEntry)
st, err := os.Stat(binPath)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("proc plugin %s: 检查 %s: %w", name, binEntry, err)
}
if st.IsDir() {
return "", fmt.Errorf("proc plugin %s: %s 是目录,不是可执行文件", name, binEntry)
}
if st.Mode()&0o111 == 0 {
// 常见于经 zip/hmap 分发丢失权限位——给出可直接执行的修复指令
return "", fmt.Errorf("proc plugin %s: %s 缺少可执行权限chmod +x %s",
name, binEntry, binPath)
}
return binPath, nil
}
// loadProc 构造子进程插件实体(不 spawn
//
// 共享段 Host 在此惰性创建:**全部 .bin 插件共用一块段**。
// 若每插件一段,多插件同阶段并发时会退化成副本模型,
// lost update 原样复现§8.4 实测 35.8~36.8%)。
func (r *Registry) loadProc(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
binPath, err := validateProcBinary(dir, name)
if err != nil {
return nil, err
}
if binPath == "" {
return nil, nil
}
host, err := r.ensureProcHost()
if err != nil {
return nil, fmt.Errorf("proc plugin %s: %w", name, err)
}
// manifest 声明的能力集§3.8 权限梯度)。
// 无 manifest 或未声明 capabilities 时不限制,保存存量插件行为。
var caps []string
if mft := readManifest(dir); mft != nil {
caps = mft.Capabilities
if len(caps) > 0 {
log.Printf("[plugin] %s 声明能力: %v", name, caps)
}
}
return procPluginAdapter{
Plugin: proc.New(name, binPath, dir, config, host, r.onProcCrash, caps...),
}, nil
}
// ensureProcHost 惰性创建共享段 Host全进程唯一
func (r *Registry) ensureProcHost() (*proc.Host, error) {
r.procHostMu.Lock()
defer r.procHostMu.Unlock()
if r.procHost != nil {
return r.procHost, nil
}
host, err := proc.NewHost()
if err != nil {
return nil, err
}
r.procHost = host
// 事件环适配层Bus 发布 → 写 EvtRing slot → eventfd 通知子进程
if r.evBus != nil {
er := NewEventRing(host.EvtRing(), int(host.Evtfd().Fd()), r.evBus)
host.SetEvtSubscriber(er)
log.Printf("[plugin] 事件环已创建Bus → EvtRing → eventfd")
}
log.Printf("[plugin] 共享段已创建(全部子进程插件共用一块,%d KB", host.ShmSize()/1024)
return host, nil
}
// closeProcHost 释放共享段(仅在内核关停时调用)。
func (r *Registry) closeProcHost() {
r.procHostMu.Lock()
defer r.procHostMu.Unlock()
if r.procHost == nil {
return
}
if err := r.procHost.Close(); err != nil {
log.Printf("[plugin] 关闭共享段: %v", err)
}
r.procHost = nil
}
// onProcCrash 在子进程插件异常退出时回调。
//
// **崩溃隔离**子进程死亡只影响自己homed 继续服务——对比 C ABI 下
// 插件 panic 直接带崩整个进程§1.2,现网已发生)。
//
// 但「homed 没崩」不等于「内核状态干净」。此前本函数只发了一个事件,
// 而全仓没有任何订阅者,于是生产上出现过 editdoc 被 kill 后:
// - `edit_document` 仍留在 StageHost 的工具表里,模型照旧看得到、照旧调用,
// 每次都吃到 `proc: 插件进程已退出`
// - 该插件的 stage handler 仍在每轮 RunStage 里被并发调起并失败;
// - 没有任何路径把它拉回来,插件永久缺席直到重启 homed。
//
// 所以崩溃回调必须做三件事:摘注册面、喂健康计数、排一次重启。
func (r *Registry) onProcCrash(name string, err error) {
log.Printf("[plugin] 子进程插件 %s 异常退出: %vhomed 未受影响)", name, err)
// 1) 摘掉工具/stage/通道。**必须先做**:从这一刻起模型就不该再看到这些工具,
// 否则在重启完成前的窗口里每次调用都是确定的失败。
r.detachPlugin(name)
// 2) 从注册表移除。不做的后果scheduleProcRestart 里的“已被其他路径重新加载”
// 复核会误判旧条目还在See plugins[name] != nil跳过真正的自动重启。
// Plugin 对象本身仍被 proc 持有Kill/回收不受影响。
r.mu.Lock()
delete(r.plugins, name)
delete(r.sdkRefs, name)
for i, inst := range r.instances {
if inst.Name() == name {
r.instances = append(r.instances[:i], r.instances[i+1:]...)
break
}
}
r.mu.Unlock()
// 2) 事件通知webui/诊断插件可订阅)。
if r.evBus != nil {
r.evBus.Publish(&events.Event{
Type: events.EventSystem,
Source: "plugin",
Payload: map[string]interface{}{
"event": "plugin_crashed",
"plugin": name,
"error": err.Error(),
},
Timestamp: time.Now().Unix(),
})
}
// 3) 排一次重启。**必须异步**:本回调由 proc.markExited 在 readLoop 的
// goroutine 里触发,而 ReloadOne 要拿 registry 锁、还要 Kill 并 join 同一个
// readLoopProcess.Kill 里 readerWG.Wait同步调用会自锁死。
go r.scheduleProcRestart(name, err)
}
// scheduleProcRestart 在崩溃后按退避重启子进程插件。
//
// 退避与阈值语义与 agent 侧 plugin_health 对齐(窗口内 3 次即判定不健康),
// 但重启动作落在 registry崩溃事实产生于此agent 的 distillLoop 默认 30 分钟
// 才转一次(生产实配 2d靠它兜底等于插件缺席数小时。
func (r *Registry) scheduleProcRestart(name string, cause error) {
if r.shuttingDown.Load() {
return // 内核正在关停,不再拉起
}
if !r.AutoRestartEnabled(name) {
log.Printf("[plugin] %s 声明了不自动重启,保持缺席状态", name)
return
}
n := r.noteCrash(name)
if n > procMaxRestarts {
log.Printf("[plugin] %s 在 %v 内崩溃 %d 次,停止自动重启(需人工介入)",
name, procCrashWindow, n)
return
}
// 线性退避1 次→1s2 次→2s3 次→3s。崩溃循环时不至于打满 CPU
// 又足够快到用户感知不到工具缺席。
delay := time.Duration(n) * procRestartBackoff
time.Sleep(delay)
// 期间可能已被 Disable/Remove/手工 plgreload 处理掉,重启前复核。
if r.shuttingDown.Load() {
return
}
if r.isDisabled(name) {
log.Printf("[plugin] %s 已被禁用,取消自动重启", name)
return
}
r.mu.RLock()
already := r.plugins[name] != nil
r.mu.RUnlock()
if already {
log.Printf("[plugin] %s 已被其他路径重新加载,取消自动重启", name)
return
}
log.Printf("[plugin] 自动重启 %s第 %d 次,退避 %v起因: %v", name, n, delay, cause)
if err := r.ReloadOne(name); err != nil {
log.Printf("[plugin] %s 自动重启失败: %v", name, err)
return
}
log.Printf("[plugin] %s 自动重启成功", name)
}
// noteCrash 记录一次崩溃并返回窗口内的累计次数。
func (r *Registry) noteCrash(name string) int {
now := time.Now()
r.crashMu.Lock()
defer r.crashMu.Unlock()
if r.procCrashes == nil {
r.procCrashes = make(map[string]*procCrashRecord)
}
rec := r.procCrashes[name]
if rec == nil || now.Sub(rec.last) > procCrashWindow {
rec = &procCrashRecord{}
r.procCrashes[name] = rec
}
rec.count++
rec.last = now
return rec.count
}
// ResetProcCrashCount 清空某插件的崩溃计数(人工 plgreload / 重新启用后调用)。
func (r *Registry) ResetProcCrashCount(name string) {
r.crashMu.Lock()
defer r.crashMu.Unlock()
delete(r.procCrashes, name)
}

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,200 @@
package plugin
import (
"os"
"path/filepath"
"strings"
"testing"
)
// entry 分派docs/zh/plugin-migration-plan.md Part 1/6
//
// C ABI 通道(.so/.dll/.dylib已整体退场外部插件统一走子进程 + stdio RPC。
// 这些测试守住的是「旧产物给明确错误」而非「静默跳过」——后者会让
// 「插件目录在但没加载」看起来像配置问题。
func TestClassifyEntry(t *testing.T) {
cases := []struct {
entry string
want entryKind
}{
{"plugin.bin", entryProc},
{"main.lua", entryLua},
{"SKILL.md", entrySkill},
{"", entryUnknown},
{"plugin.wasm", entryUnknown},
// 已退场的 C ABI 产物不再是有效通道
{"plugin.so", entryUnknown},
{"plugin.dll", entryUnknown},
{"plugin.dylib", 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()
mustWrite(t, filepath.Join(dir, "main.lua"), "fake lua")
mustWrite(t, filepath.Join(dir, "plugin.bin"), "fake bin")
mustWrite(t, filepath.Join(dir, metaEntry), `{"name":"x","entry":"main.lua"}`)
if got := detectEntryKind(dir); got != entryLua {
t.Fatalf("manifest 声明 main.lua 应走 lua实际 %v", got)
}
}
// 存量插件的 plugin.json 仍写着 "plugin.so"(历史产物),
// 此时 classifyEntry 返回 unknown须靠目录探测找到 plugin.bin。
//
// 这是「外部插件零改动」的直接后果17 个插件的 manifest 没人去改。
func TestDetectEntryKind_LegacyManifestFallsBackToProbe(t *testing.T) {
dir := t.TempDir()
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 != entryProc {
t.Fatalf("manifest 写 plugin.so 但目录有 plugin.bin 时应走 proc实际 %v", got)
}
}
func TestDetectEntryKind_ProbeFallbacks(t *testing.T) {
t.Run("only bin", func(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.bin"), "x")
if got := detectEntryKind(dir); got != entryProc {
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)
}
})
t.Run("only legacy so", func(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.so"), "x")
if got := detectEntryKind(dir); got != entryUnknown {
t.Fatalf("只有 .so 时应为 unknownC ABI 已退场),实际 %v", got)
}
})
}
// C ABI 残留必须能被识别,供 tryDynamic 给出「需要重编」的明确错误。
func TestHasLegacyCABIEntry(t *testing.T) {
for _, name := range []string{"plugin.so", "plugin.dll", "plugin.dylib"} {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, name), "x")
if !hasLegacyCABIEntry(dir) {
t.Errorf("%s 应被识别为 C ABI 残留", name)
}
}
t.Run("clean dir", func(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.bin"), "x")
if hasLegacyCABIEntry(dir) {
t.Error("只有 plugin.bin 的目录不应被判为 C ABI 残留")
}
})
}
// 旧 .so 插件必须报「用新 plugindev 重编」而非静默跳过。
func TestTryDynamic_LegacyCABIGivesActionableError(t *testing.T) {
r := NewRegistry()
defer r.closeProcHost()
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.so"), "old cabi binary")
_, err := r.tryDynamic(dir, "legacy", nil)
if err == nil {
t.Fatal("旧 C ABI 产物应报错,不得静默跳过")
}
// 错误消息须指向解决办法,且明确业务代码无需改
msg := err.Error()
for _, want := range []string{"plugindev", "plugin.bin", "业务代码"} {
if !strings.Contains(msg, want) {
t.Errorf("错误消息应含 %q实际: %v", want, err)
}
}
}
// entry 声明 plugin.bin 但二进制缺失时返回 nil,nil交由后续探测
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_UsesBin(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.bin"), "bin content")
h1 := pluginEntryHash(dir)
if h1 == "" {
t.Fatal("应算出 hash")
}
mustWrite(t, filepath.Join(dir, "plugin.bin"), "bin content CHANGED")
if h2 := pluginEntryHash(dir); h2 == h1 {
t.Error("plugin.bin 变化必须反映到 hash否则增量重载失效")
}
}
// C ABI 产物不再参与 hash 计算:内核已不认它,把它算进去会让
// 「换了 .so」触发一次无意义的重载尝试。
func TestPluginEntryHash_IgnoresLegacyCABI(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.so"), "so content")
if h := pluginEntryHash(dir); h != "" {
t.Errorf("只有 .so 时应返回空串C ABI 已退场),实际 %q", h)
}
}
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

@ -0,0 +1,55 @@
package plugin
import (
"encoding/json"
"sync"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin/proc"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// EventRing 是 Bus 与 proc.EvtRing 之间的适配层。
//
// 把内核的事件总线接到共享内存事件环Bus.Publish → handler
// 把事件序列化写入 EvtRing slot → eventfd 通知子进程。
// 不改 Bus 自身结构(保护零 API 变动)。
type EventRing struct {
ring *proc.EvtRing
bus *events.Bus
efd int
mu sync.Mutex
}
func NewEventRing(ring *proc.EvtRing, efd int, bus *events.Bus) *EventRing {
return &EventRing{ring: ring, bus: bus, efd: efd}
}
// Subscribe 在 Bus 上注册一个把事件分发到事件环的 handler返回取消函数。
//
// 不改 Bus 自身结构——handler 把事件序列化后写入环并 post eventfd
// Bus 侧按 EventType 精确匹配分发(与现有逻辑完全一致)。
func (er *EventRing) Subscribe(eventType pubsdk.EventType) func() {
return er.bus.Subscribe(events.EventType(eventType), func(evt *events.Event) {
payload, err := json.Marshal(evt)
if err != nil {
return
}
er.ring.WritePush(pubsdk.EventType(evt.Type), payload)
proc.EvtfdNotify(er.efd)
})
}
// EvtRingSubscribe 实现 proc.EvtRingSubscriber 接口。
// 按事件类型列表订阅,返回统一取消函数。
func (er *EventRing) EvtRingSubscribe(types []pubsdk.EventType) func() {
unsubscribes := make([]func(), 0, len(types))
for _, t := range types {
unsubscribes = append(unsubscribes, er.Subscribe(t))
}
return func() {
for _, fn := range unsubscribes {
fn()
}
}
}

View File

@ -0,0 +1,160 @@
//go:build linux || darwin
package plugin
import (
"testing"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin/proc"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// 事件环基础测试Host 创建事件环 → EventRing 写入 → 消费者读到。
func TestEventRing_BasicWriteAndConsume(t *testing.T) {
host, err := proc.NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
bus := events.NewBus()
er := NewEventRing(host.EvtRing(), int(host.Evtfd().Fd()), bus)
// 消费者:从事件环段读取事件
received := make(chan *pubsdk.Event, 10)
consumer := proc.NewEvtConsumer(
host.EvtData(),
host.EvtfdReadFile(),
0, // typeMask = 0接收全部事件
func(evt *pubsdk.Event) error {
received <- evt
return nil
},
)
go consumer.Run()
defer consumer.Stop()
// 订阅 agent_output 事件
unsub := er.Subscribe(pubsdk.EventAgentOutput)
defer unsub()
// 发布事件
bus.Publish(&events.Event{
Type: events.EventAgentOutput,
Payload: map[string]interface{}{"text": "hello"},
})
// 等待消费者读到
select {
case evt := <-received:
if evt.Type != pubsdk.EventType(events.EventAgentOutput) {
t.Errorf("事件类型 = %v期望 %v", evt.Type, events.EventAgentOutput)
}
case <-time.After(2 * time.Second):
t.Error("消费者在 2s 内未收到事件")
}
}
// 事件环溢出测试:写入超过 cap 时消费者仍能读到最新事件。
func TestEventRing_OverflowStillDelivers(t *testing.T) {
host, err := proc.NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
bus := events.NewBus()
er := NewEventRing(host.EvtRing(), int(host.Evtfd().Fd()), bus)
// 不启动消费者,直接写入超过 cap 的事件(需先订阅,否则 Bus 不会触发事件环写入)
unsub := er.Subscribe(pubsdk.EventSystem)
defer unsub()
for i := uint32(0); i < 8192+100; i++ {
bus.Publish(&events.Event{
Type: events.EventSystem,
Payload: map[string]interface{}{"seq": i},
})
}
// 启动消费者,应能读到最新事件
received := make(chan *pubsdk.Event, 10)
consumer := proc.NewEvtConsumer(
host.EvtData(),
host.EvtfdReadFile(),
0,
func(evt *pubsdk.Event) error {
received <- evt
return nil
},
)
go consumer.Run()
defer consumer.Stop()
select {
case evt := <-received:
if evt == nil {
t.Error("收到 nil 事件")
}
case <-time.After(2 * time.Second):
t.Error("溢出后消费者在 2s 内未收到事件")
}
}
// typeMask 过滤测试:订阅者只收到匹配类型的事件。
func TestEventRing_TypeMaskFiltering(t *testing.T) {
host, err := proc.NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
bus := events.NewBus()
er := NewEventRing(host.EvtRing(), int(host.Evtfd().Fd()), bus)
received := make(chan *pubsdk.Event, 10)
// typeMask 只订阅 tool_callbit 3 = 8
consumer := proc.NewEvtConsumer(
host.EvtData(),
host.EvtfdReadFile(),
1<<3, // tool_call
func(evt *pubsdk.Event) error {
received <- evt
return nil
},
)
go consumer.Run()
defer consumer.Stop()
unsub := er.Subscribe(pubsdk.EventToolCall)
defer unsub()
// 发一个 tool_call 和一个 agent_output
bus.Publish(&events.Event{
Type: events.EventToolCall,
Payload: map[string]interface{}{"tool": "test"},
})
bus.Publish(&events.Event{
Type: events.EventAgentOutput,
Payload: map[string]interface{}{"text": "should be filtered"},
})
// 只应收到 tool_call
select {
case evt := <-received:
if evt.Type != pubsdk.EventType(events.EventToolCall) {
t.Errorf("收到错误类型 %v期望 tool_call", evt.Type)
}
case <-time.After(2 * time.Second):
t.Error("消费者在 2s 内未收到 tool_call 事件")
}
// agent_output 不应到达
select {
case evt := <-received:
t.Errorf("不应收到 agent_output实际收到 %v", evt)
case <-time.After(200 * time.Millisecond):
// 正确agent_output 被过滤
}
}

View File

@ -19,11 +19,25 @@ 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"(子进程) | "main.lua" | "SKILL.md"
Platforms []string `json:"platforms,omitempty"` // 声明的支持平台: ["linux","darwin","windows"]
MinVersion string `json:"min_version,omitempty"`
Tags []string `json:"tags,omitempty"`
Deprecated bool `json:"deprecated,omitempty"`
// Capabilities 声明本插件需要的内核能力组§3.8 权限梯度)。
//
// 取值见 internal/plugin/proc.KnownCapabilities()
// io / memory / doc_memory / knowledge / text_memory / llm / social /
// events / plugin_mgr / settings_cross
//
// **省略或为空 = 不受限**,而不是「只有基础能力」。
// 理由17 个存量插件的 plugin.json 都没有这个字段,若空声明当作最小权限,
// 它们会全部失去 IO 注入、记忆读写等能力而**静默降级**——
// 违反「外部插件零改动」的硬约束。收紧的路径是让插件显式声明。
//
// core注册自身工具/阶段/通道 + 读写自己的配置)无需声明,始终可用。
Capabilities []string `json:"capabilities,omitempty"`
}
func ReadManifest(dir string) (*PluginManifest, error) {

View File

@ -0,0 +1,222 @@
package proc
import (
"os"
"os/exec"
"path/filepath"
"testing"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// 子进程架构的性能基准Part 6.6 验收项)。
//
// 对照基线来自 docs/zh/experiments/plugin-arch
//
// 实验 3 锁仲裁 RPC 往返 19.40 µs/次
// 实验 4 post-and-forget 5.07s → 2.29ms5000 token + 20µs 慢消费者)
// 实验 11 工具调用 RPC p50 19.6 µs
//
// 这些基准回答的是「进程边界的代价是否可忽略」——相对 stage handler 的实际
// 工作量LLM 往返 2-8 秒),微秒级往返不构成问题;但若退化到毫秒级,
// 高频工具调用就会被感知。
//
// 实测结果2026-09-02AMD Ryzen 7 7840HS
//
// ToolInvoke 24.1 µs/op ← 对照实验 11 的 19.6µs同量级
// StageLockArbitration 0.76 µs/op ← 仅内核侧仲裁,不跨进程
// EvtRingWritePush 95 ns/op
// EvtRingWritePushConcurrent 83 ns/op ← 并发不恶化
// StageInvokeSharedMemory 132 µs/op ← 含 3 次进程间往返
// SegmentWriteAllReadInto 3.7 µs/op ← 占 stage 的 2.8%
// buildBenchPlugin 编译 testdata 里的测试插件benchmark 版)。
func buildBenchPlugin(b *testing.B, srcName string) string {
b.Helper()
src := filepath.Join("testdata", srcName)
if _, err := os.Stat(src); err != nil {
b.Skipf("测试插件源码缺失 %s: %v", src, err)
}
bin := filepath.Join(b.TempDir(), "benchplugin")
cmd := exec.Command("go", "build", "-o", bin, src)
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
if out, err := cmd.CombinedOutput(); err != nil {
b.Fatalf("编译 %s: %v\n%s", srcName, err, out)
}
return bin
}
// BenchmarkToolInvoke 测量内核 → 插件的工具调用往返。
//
// 链路Call 写 stdin → 插件读循环 → handler → 写 stdout →
// 内核 readLoop → pending channel 唤醒。对照实验 11 的 19.6µs。
func BenchmarkToolInvoke(b *testing.B) {
bin := buildBenchPlugin(b, "echoplugin.go")
p, err := Spawn("echo", bin, Options{Handler: noopHandler})
if err != nil {
b.Fatalf("Spawn: %v", err)
}
defer p.Kill()
args := map[string]interface{}{"text": "benchmark"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := p.Call(MethodToolInvoke, ToolInvokeParams{
Name: "echo_tool",
Args: args,
}); err != nil {
b.Fatalf("第 %d 次调用失败: %v", i, err)
}
}
}
// BenchmarkStageLockArbitration 测量**内核侧锁仲裁本身**的成本。
//
// ⚠️ 不要拿这个数字对照实验 3 的 19.40µs——两者测的不是同一个东西
// - 实验 3插件经 RPC 请求锁的**完整跨进程往返**
// - 本基准:仅 lockRegistry.acquire/release不跨进程
//
// 真实成本仍在 20µs 量级(那部分是 RPC 往返,见 BenchmarkToolInvoke
// 本基准的用途是确认仲裁逻辑自身不是瓶颈:若它也到了微秒级,
// 说明 sync.Mutex 之外又引入了什么开销。
func BenchmarkStageLockArbitration(b *testing.B) {
lock := newStageLock()
r := &lockRegistry{}
r.bind(lock)
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := r.acquire("bench"); err != nil {
b.Fatalf("acquire: %v", err)
}
if err := r.release("bench"); err != nil {
b.Fatalf("release: %v", err)
}
}
}
// BenchmarkEvtRingWritePush 测量事件环写入Bus.Publish 路径)。
//
// 这是 §4.3 标记「风险高」的那一项:流式输出逐 token 发布,
// Publish 路径上任何阻塞都会直接卡顿。
func BenchmarkEvtRingWritePush(b *testing.B) {
host, err := NewHost()
if err != nil {
b.Fatalf("NewHost: %v", err)
}
defer host.Close()
ring := host.EvtRing()
payload := []byte(`{"type":"content_delta","payload":{"text":"token"}}`)
b.ResetTimer()
for i := 0; i < b.N; i++ {
ring.WritePush(pubsdk.EventContentDelta, payload)
}
}
// BenchmarkEvtRingWritePushConcurrent 并发写入。
//
// 内核有多条路径并发发布事件(主循环、工具调用、流式增量),
// writeSeq 是 atomic 而 arena 分配有锁——确认锁不是瓶颈。
func BenchmarkEvtRingWritePushConcurrent(b *testing.B) {
host, err := NewHost()
if err != nil {
b.Fatalf("NewHost: %v", err)
}
defer host.Close()
ring := host.EvtRing()
payload := []byte(`{"type":"content_delta","payload":{"text":"tok"}}`)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
ring.WritePush(pubsdk.EventContentDelta, payload)
}
})
}
// BenchmarkStageInvokeSharedMemory 测量完整 stage 往返:
// 写共享段 → RPC → 插件读改写 → 回读 → 压实。
//
// 这是迁移引入的最重路径,每次 stage 都要走一遍。
//
// 实测 ~132µs比单次 RPC~24µs高 5 倍,因为**一次 stage 要走 3 次
// 进程间往返**stage.invoke + 插件侧反向的 stage.lock / stage.unlock。
// 共享段编解码只占 3.7µs2.8%)——成本在往返次数而非数据搬运。
//
// 相对 LLM 往返 2-8 秒可忽略。若日后要优化,方向是把 lock/unlock
// 合入 stage.invoke 的请求/应答,省掉两次往返。
func BenchmarkStageInvokeSharedMemory(b *testing.B) {
bin := buildBenchPlugin(b, "stageplugin.go")
host, err := NewHost()
if err != nil {
b.Fatalf("NewHost: %v", err)
}
defer host.Close()
core := newFakeCore()
p := New("sanitizer", bin, b.TempDir(), nil, host, nil)
if err := p.Start(core); err != nil {
b.Fatalf("Start: %v", err)
}
defer p.Close()
handlers := core.stageHandlers(pubsdk.StageAfterToolcall)
if len(handlers) != 1 {
b.Fatalf("应注册 1 个 handler实际 %d", len(handlers))
}
handler := handlers[0]
b.ResetTimer()
for i := 0; i < b.N; i++ {
sc := &pubsdk.StageContext{
Phase: pubsdk.StageAfterToolcall,
ToolResults: []pubsdk.ToolResult{
{CallID: "c1", Name: "t", Result: "结果:\x1b[31m脏\x1b[0m"},
},
}
if err := handler(sc); err != nil {
b.Fatalf("第 %d 次 stage 失败: %v", i, err)
}
}
}
// BenchmarkSegmentWriteAllReadInto 只测共享段编解码(不含 RPC
//
// 用于拆分 stage 往返的成本构成:编解码 vs 进程间通信。
func BenchmarkSegmentWriteAllReadInto(b *testing.B) {
host, err := NewHost()
if err != nil {
b.Fatalf("NewHost: %v", err)
}
defer host.Close()
seg := host.Segment()
sc := &pubsdk.StageContext{
Phase: pubsdk.StageAfterToolcall,
RawMessage: "用户输入的一段话",
UserID: "u1",
LLMText: "模型输出的文本",
FinalText: "最终文本",
ToolResults: []pubsdk.ToolResult{
{CallID: "c1", Name: "tool_a", Result: "结果 A"},
{CallID: "c2", Name: "tool_b", Result: "结果 B"},
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := seg.WriteAll(sc); err != nil {
b.Fatalf("WriteAll: %v", err)
}
if err := seg.ReadInto(sc); err != nil {
b.Fatalf("ReadInto: %v", err)
}
seg.Compact()
}
}

View File

@ -0,0 +1,267 @@
package proc
import (
"fmt"
"sort"
"strings"
)
// 权限梯度:外部插件可调用哪些内核 method§3.8)。
//
// 迁移前,「外部插件拿不到 Selftest/Supervisor/Tracker」是 C ABI 表达能力的
// **意外产物**——C 结构体不好传函数指针,于是这些能力自然到不了插件侧。
// 那是运气,不是策略:任何人给 dispatch 加个 case 就能捅穿。
//
// 迁移后要变成**显式声明并强制的策略**,分三道闸:
//
// 1. 类型层internal/plugin/proc_core.goprocCore 用命名字段持有内核 SDK
// 不嵌入 —— 未在收窄面显式写出的方法根本不存在,编译期就拿不到。
// 2. 能力集本文件method 划入 capability 组manifest 未声明的组被拒。
// 3. RPC 边界:被拒时返回**明确错误**而非静默忽略——插件作者能立刻知道
// 「这个能力没给我」,而不是调用成功但什么也没发生。
//
// 第 3 条针对的是一类真实故障C ABI 时代 case 23/24事件订阅是空实现
// 返回成功但永远收不到事件§1.3 的「给不了」而非「不给」)。
// Capability 是一组相关 method 的权限单元。
//
// 粒度选择:按**能力域**而非单个 method 划分。逐 method 授权看似更精细,
// 但插件作者要在 manifest 里列 60 个名字,且内核加 method 时所有 manifest 都得改。
type Capability string
const (
// CapCore 是无需声明即可用的基础能力:注册自身工具/阶段/通道、
// 读写自己的配置、共享段锁仲裁。没有这些插件无法工作。
CapCore Capability = "core"
// CapIO 注入输入到 agent 主循环(可影响对话流)。
CapIO Capability = "io"
// CapMemory 图记忆读写。
CapMemory Capability = "memory"
// CapDocMemory 文档记忆读写。
CapDocMemory Capability = "doc_memory"
// CapKnowledge 知识库读写。
CapKnowledge Capability = "knowledge"
// CapTextMemory 文本记忆追加。
CapTextMemory Capability = "text_memory"
// CapLLM 切换 LLM 源(影响全局行为)。
CapLLM Capability = "llm"
// CapSocial 社交图读取。
CapSocial Capability = "social"
// CapEvents 订阅内核事件。
CapEvents Capability = "events"
// CapPluginMgr 管理其他插件(重载/查询禁用状态)。
//
// 这是**最敏感**的一组:能重载其他插件意味着能间接影响它们的状态。
CapPluginMgr Capability = "plugin_mgr"
// CapCrossPluginSettings 读写**其他插件**的配置与内核核心配置。
//
// 与 CapCore 里的「读写自己的配置」区分开:跨插件配置读写能改别人的行为,
// 核心配置读写能改内核行为。
CapCrossPluginSettings Capability = "settings_cross"
)
// methodCapability 把每个 method 映射到所需能力。
//
// ❗ 新增 method 时必须在此登记,否则 capabilityOf 返回 CapCore
// 最宽松等于绕过权限检查。checkAllMethodsClassified 测试守着这一点。
var methodCapability = map[string]Capability{
// ---- 基础能力(无需声明)----
MethodHandshake: CapCore,
MethodToolRegister: CapCore,
MethodStageRegister: CapCore,
MethodOutputRegister: CapCore,
MethodAPIRegister: CapCore,
MethodInputRegister: CapCore,
MethodStageLock: CapCore,
MethodStageUnlock: CapCore,
// 自身配置读写与元信息属基础能力
MethodSettingsGet: CapCore,
MethodSettingsSet: CapCore,
MethodSettingsList: CapCore,
MethodSettingsDefs: CapCore,
MethodSettingsRegisterDef: CapCore,
MethodSettingsDataDir: CapCore,
// 生命周期自述(插件声明自己是否可自动重启)
MethodLifecycleAutoRestart: CapCore,
// 多模态内容块注入是工具返回值的一部分,不越权
MethodIOSetToolBlocks: CapCore,
// ---- IO 注入 ----
MethodIOInjectText: CapIO,
MethodIOInjectInterrupt: CapIO,
MethodIOInjectTextNoMem: CapIO,
MethodIOInjectSync: CapIO,
// ---- 图记忆 ----
MethodMemoryRecall: CapMemory,
MethodMemoryCommit: CapMemory,
MethodMemoryIntrospect: CapMemory,
MethodMemoryMerge: CapMemory,
MethodMemoryPurge: CapMemory,
// ---- 文档记忆 ----
MethodDocQuery: CapDocMemory,
MethodDocInsert: CapDocMemory,
MethodDocRemove: CapDocMemory,
MethodDocStats: CapDocMemory,
// ---- 知识库 ----
MethodKnowledgeSearch: CapKnowledge,
MethodKnowledgeAdd: CapKnowledge,
MethodKnowledgeList: CapKnowledge,
// ---- 文本记忆 ----
MethodTextMemoryAppend: CapTextMemory,
// ---- LLM ----
MethodLLMListSources: CapLLM,
MethodLLMSetSource: CapLLM,
MethodLLMCurrentSource: CapLLM,
// ---- 社交图 ----
MethodSocialGetPerson: CapSocial,
MethodSocialGetNetwork: CapSocial,
MethodSocialGetTrait: CapSocial,
MethodSocialGetRelation: CapSocial,
MethodSocialListPersons: CapSocial,
// ---- 事件 ----
MethodEventsSubscribe: CapEvents,
MethodEventsUnsubscribe: CapEvents,
// ---- 插件管理 ----
MethodPluginReloadOne: CapPluginMgr,
MethodPluginListLoaded: CapPluginMgr,
MethodPluginIsDisabled: CapPluginMgr,
// ---- 跨插件 / 核心配置 ----
MethodSettingsGetCore: CapCrossPluginSettings,
MethodSettingsSetCore: CapCrossPluginSettings,
MethodSettingsListCore: CapCrossPluginSettings,
MethodSettingsGetPlugin: CapCrossPluginSettings,
MethodSettingsSetPlugin: CapCrossPluginSettings,
MethodSettingsListPlugin: CapCrossPluginSettings,
MethodSettingsDump: CapCrossPluginSettings,
MethodSettingsPlugins: CapCrossPluginSettings,
}
// withheldCapabilities 是**刻意不提供给外部插件**的内核内部机制§3.8 最后一行)。
//
// 这些没有对应的 method 常量——不是"忘了加",是决定不加。
// 列在这里是为了让决策可见:读代码的人能看到边界在哪,而不是从
// 「protocol.go 里没有」这个负面事实去推断。
//
// 类型层已经挡住了procCore 不暴露这些访问器),本表是文档 + 测试锚点。
var withheldCapabilities = map[string]string{
"SelftestAPI": "虚拟实例自检 —— 能构造内核实例,等于绕过全部权限边界",
"SupervisorAPI": "进程监管 —— 能启停 worker等于控制内核生命周期",
"TrackerAPI": "变更追踪 —— 内核 overlay 文件系统的内部机制",
"StatusAPI": "内核状态面 —— 暴露内部运行时细节",
"AdapterAPI": "LLM 适配器管理 —— 能改写请求/响应链路",
"ConfigAPI": "内核配置对象 —— 与 settings 的受控读写不同,这是直接持有",
"ToolAPI": "工具表直接操作 —— 能注销其他插件的工具(注册自己的工具走 tool.register那是 core",
"IndexerAPI": "记忆索引器 —— 内核记忆管线的内部组件",
"OutputChanRaw": "输出通道原始消费 —— 已由 output.invoke 的声明式注册替代",
"EventPublish": "事件发布 —— 只给订阅events.subscribe不给伪造内核事件",
}
// capabilityOf 返回 method 所需能力。
//
// 未登记的 method 返回 (CapCore, false)ok=false 让调用方能区分
// 「明确划为基础能力」与「漏登记」,测试据此拦住漏登记。
func capabilityOf(method string) (Capability, bool) {
cap, ok := methodCapability[method]
if !ok {
return CapCore, false
}
return cap, true
}
// capabilitySet 是某个插件被授予的能力集合。
type capabilitySet struct {
granted map[Capability]bool
// unrestricted 为真时跳过检查(未声明 capabilities 的插件,向后兼容)。
unrestricted bool
}
// newCapabilitySet 从 manifest 声明构造能力集。
//
// **空声明 = 不受限**,而不是「只有 core」。理由17 个存量插件的 plugin.json
// 都没有 capabilities 字段,若空声明当作最小权限,它们会全部失去 IO 注入、
// 记忆读写等能力而**静默降级**——这违反「外部插件零改动」的硬约束。
//
// 收紧的路径是让插件显式声明,而非默默拒绝老插件。
func newCapabilitySet(declared []string) *capabilitySet {
if len(declared) == 0 {
return &capabilitySet{unrestricted: true}
}
s := &capabilitySet{granted: map[Capability]bool{CapCore: true}}
for _, d := range declared {
s.granted[Capability(strings.TrimSpace(d))] = true
}
return s
}
// allows 判断是否允许调用某 method。
func (s *capabilitySet) allows(method string) (bool, Capability) {
cap, registered := capabilityOf(method)
if !registered {
// 漏登记的 method 按基础能力放行(保守:不因内核疏漏拦住插件),
// 但由测试保证这种情况不存在。
return true, CapCore
}
if s == nil || s.unrestricted {
return true, cap
}
if cap == CapCore {
return true, cap
}
return s.granted[cap], cap
}
// KnownCapabilities 返回全部可声明的能力名(供 manifest 校验与文档生成)。
func KnownCapabilities() []string {
seen := map[Capability]bool{}
for _, c := range methodCapability {
seen[c] = true
}
out := make([]string, 0, len(seen))
for c := range seen {
if c == CapCore {
continue // core 无需声明
}
out = append(out, string(c))
}
sort.Strings(out)
return out
}
// WithheldCapabilities 返回刻意不提供的能力清单(供文档与诊断)。
func WithheldCapabilities() map[string]string {
out := make(map[string]string, len(withheldCapabilities))
for k, v := range withheldCapabilities {
out[k] = v
}
return out
}
// errCapabilityDenied 构造被拒错误。
//
// 消息包含三要素:被拒的 method、缺的能力名、如何补救。
// 静默忽略或含糊的「失败」会让插件作者以为是自己参数错了。
func errCapabilityDenied(plugin, method string, cap Capability) error {
return fmt.Errorf(
"插件 %s 调用 %s 被拒:缺少 %q 能力。"+
"请在 plugin.json 的 capabilities 数组中声明它(可用能力:%s",
plugin, method, cap, strings.Join(KnownCapabilities(), ", "))
}

View File

@ -0,0 +1,322 @@
package proc
import (
"encoding/json"
"strings"
"testing"
)
// 权限梯度测试§3.8)。
//
// 守住的核心性质:外部插件拿不到内核内部机制,不是因为 C ABI 传不了
// 函数指针(那是运气),而是因为这里**显式声明并强制**了边界。
// 每个 method 都必须登记能力归属。
//
// ❗ 这是本文件最重要的测试:漏登记的 method 会按 CapCore 放行,
// 等于绕过整套权限检查。新增 method 时忘了登记,这里会当场报出来。
func TestCapability_AllMethodsClassified(t *testing.T) {
// 与 protocol.go 的 method 常量对齐。内核→插件的 7 个调用不经 Handle
// 故不需要能力归属。
kernelToPlugin := map[string]bool{
MethodPluginInit: true,
MethodPluginStart: true,
MethodPluginStop: true,
MethodToolInvoke: true,
MethodStageInvoke: true,
MethodOutputInvoke: true,
}
// 插件→内核的全部 method手工清单与 protocol.go 对照)
pluginToKernel := []string{
MethodHandshake,
MethodToolRegister, MethodStageRegister, MethodOutputRegister,
MethodAPIRegister, MethodInputRegister,
MethodIOInjectText, MethodIOInjectInterrupt, MethodIOInjectTextNoMem,
MethodIOInjectSync, MethodIOSetToolBlocks,
MethodLifecycleAutoRestart,
MethodMemoryRecall, MethodMemoryCommit, MethodMemoryIntrospect,
MethodMemoryMerge, MethodMemoryPurge,
MethodDocQuery, MethodDocInsert, MethodDocRemove, MethodDocStats,
MethodKnowledgeSearch, MethodKnowledgeAdd, MethodKnowledgeList,
MethodTextMemoryAppend,
MethodSettingsGet, MethodSettingsSet, MethodSettingsRegisterDef,
MethodSettingsGetCore, MethodSettingsSetCore, MethodSettingsListCore,
MethodSettingsGetPlugin, MethodSettingsSetPlugin, MethodSettingsListPlugin,
MethodSettingsList, MethodSettingsDefs, MethodSettingsDump,
MethodSettingsPlugins, MethodSettingsDataDir,
MethodLLMListSources, MethodLLMSetSource, MethodLLMCurrentSource,
MethodSocialGetPerson, MethodSocialGetNetwork, MethodSocialGetTrait,
MethodSocialGetRelation, MethodSocialListPersons,
MethodEventsSubscribe, MethodEventsUnsubscribe,
MethodPluginReloadOne, MethodPluginListLoaded, MethodPluginIsDisabled,
MethodStageLock, MethodStageUnlock,
}
for _, m := range pluginToKernel {
if kernelToPlugin[m] {
continue
}
if _, ok := capabilityOf(m); !ok {
t.Errorf("method %q 未登记能力归属 —— 会按 CapCore 放行,绕过权限检查", m)
}
}
}
// 未声明 capabilities 的插件不受限(存量插件向后兼容)。
//
// 若空声明当作最小权限17 个存量插件会全部失去 IO 注入/记忆读写而静默降级。
func TestCapability_EmptyDeclarationIsUnrestricted(t *testing.T) {
s := newCapabilitySet(nil)
for _, m := range []string{
MethodIOInjectText, MethodMemoryCommit, MethodPluginReloadOne,
MethodSettingsSetCore, MethodEventsSubscribe,
} {
if ok, _ := s.allows(m); !ok {
t.Errorf("未声明 capabilities 时 %q 应放行(存量插件兼容)", m)
}
}
s2 := newCapabilitySet([]string{})
if ok, _ := s2.allows(MethodMemoryCommit); !ok {
t.Error("空数组也应视为不受限")
}
}
// 声明了能力后,未声明的组被拒。
func TestCapability_DeclaredSetRestrictsOthers(t *testing.T) {
// 只声明 io能注入但不能碰记忆/插件管理/核心配置
s := newCapabilitySet([]string{"io"})
allowed := []string{MethodIOInjectText, MethodIOInjectSync}
for _, m := range allowed {
if ok, _ := s.allows(m); !ok {
t.Errorf("声明 io 后 %q 应放行", m)
}
}
denied := map[string]Capability{
MethodMemoryCommit: CapMemory,
MethodKnowledgeAdd: CapKnowledge,
MethodPluginReloadOne: CapPluginMgr,
MethodSettingsSetCore: CapCrossPluginSettings,
MethodEventsSubscribe: CapEvents,
MethodLLMSetSource: CapLLM,
MethodTextMemoryAppend: CapTextMemory,
MethodDocInsert: CapDocMemory,
MethodSocialGetPerson: CapSocial,
}
for m, wantCap := range denied {
ok, gotCap := s.allows(m)
if ok {
t.Errorf("未声明 %q 时 %q 应被拒", wantCap, m)
}
if gotCap != wantCap {
t.Errorf("%q 的能力归属 = %q期望 %q", m, gotCap, wantCap)
}
}
}
// core 能力始终可用,无需声明。
//
// 没有它插件无法注册工具、读写自己的配置、参与 stage 锁仲裁——
// 即完全无法工作。
func TestCapability_CoreAlwaysAllowed(t *testing.T) {
s := newCapabilitySet([]string{"io"}) // 只声明 io
for _, m := range []string{
MethodHandshake,
MethodToolRegister, MethodStageRegister, MethodOutputRegister,
MethodInputRegister, MethodAPIRegister,
MethodStageLock, MethodStageUnlock,
MethodSettingsGet, MethodSettingsSet, MethodSettingsList,
MethodSettingsDefs, MethodSettingsRegisterDef, MethodSettingsDataDir,
MethodLifecycleAutoRestart,
MethodIOSetToolBlocks,
} {
if ok, _ := s.allows(m); !ok {
t.Errorf("core 能力 %q 应始终放行", m)
}
}
}
// 自身配置读写属 core跨插件/核心配置需显式声明。
//
// 这个区分是有意的:读写自己的配置是插件正常工作所需;
// 读写别人的配置能改别人行为,读写核心配置能改内核行为。
func TestCapability_SettingsScopeSeparation(t *testing.T) {
s := newCapabilitySet([]string{}) // 不受限,先确认归属正确
own := []string{MethodSettingsGet, MethodSettingsSet, MethodSettingsList}
for _, m := range own {
if cap, _ := capabilityOf(m); cap != CapCore {
t.Errorf("%q 应属 core自身配置实际 %q", m, cap)
}
}
cross := []string{
MethodSettingsGetCore, MethodSettingsSetCore, MethodSettingsListCore,
MethodSettingsGetPlugin, MethodSettingsSetPlugin, MethodSettingsListPlugin,
MethodSettingsDump, MethodSettingsPlugins,
}
for _, m := range cross {
if cap, _ := capabilityOf(m); cap != CapCrossPluginSettings {
t.Errorf("%q 应属 settings_cross实际 %q", m, cap)
}
}
_ = s
}
// 被拒时错误消息必须可操作:说清缺什么、怎么补。
//
// 针对的是 C ABI 时代的一类真实故障case 23/24 返回成功但永远收不到事件,
// 插件作者无从得知。
func TestCapability_DeniedErrorIsActionable(t *testing.T) {
err := errCapabilityDenied("demo", MethodMemoryCommit, CapMemory)
msg := err.Error()
for _, want := range []string{
"demo", // 哪个插件
MethodMemoryCommit, // 哪个调用
string(CapMemory), // 缺什么能力
"capabilities", // 在哪声明
"plugin.json", // 声明在哪个文件
} {
if !strings.Contains(msg, want) {
t.Errorf("错误消息应含 %q实际: %s", want, msg)
}
}
// 还应列出可用能力名,避免作者猜
if !strings.Contains(msg, string(CapEvents)) {
t.Errorf("错误消息应列出可选能力(如 %q实际: %s", CapEvents, msg)
}
}
// coreHandler 在 Handle 入口强制权限,被拒的调用不进 switch。
func TestCapability_HandleEnforcesAtRPCBoundary(t *testing.T) {
core := newFakeCore()
h := &coreHandler{
sdk: core,
name: "restricted",
caps: newCapabilitySet([]string{"io"}), // 不含 memory
}
_, err := h.Handle(MethodMemoryCommit, json.RawMessage(`{"triples":[]}`))
if err == nil {
t.Fatal("未声明 memory 能力时 memory.commit 应被拒")
}
if !strings.Contains(err.Error(), "被拒") {
t.Errorf("应是权限拒绝错误,实际: %v", err)
}
// 已声明的能力照常走到 switch这里 memory 为 nil会返回 errUnavailable
// 但错误类型不同——证明请求进了 switch 而非被权限拦下)
h2 := &coreHandler{
sdk: core,
name: "allowed",
caps: newCapabilitySet([]string{"memory"}),
}
_, err2 := h2.Handle(MethodMemoryCommit, json.RawMessage(`{"triples":[]}`))
if err2 != nil && strings.Contains(err2.Error(), "被拒") {
t.Errorf("声明了 memory 后不应被权限拒绝,实际: %v", err2)
}
}
// 刻意不提供的内核内部机制必须有明确记录。
//
// 这些没有对应 method 常量——不是忘了加,是决定不加。
// 列表存在本身就是「这是策略而非疏漏」的证据。
func TestCapability_WithheldListIsDocumented(t *testing.T) {
withheld := WithheldCapabilities()
// §3.8 明确列为「不提供」的
for _, name := range []string{"SelftestAPI", "SupervisorAPI", "TrackerAPI"} {
reason, ok := withheld[name]
if !ok {
t.Errorf("%s 应在 withheld 清单中§3.8 明确不提供)", name)
continue
}
if reason == "" {
t.Errorf("%s 缺少不提供的理由", name)
}
}
// 每一项都必须有理由,否则读代码的人无从判断边界为何在此
for name, reason := range withheld {
if strings.TrimSpace(reason) == "" {
t.Errorf("withheld 项 %q 缺少理由", name)
}
}
// 这些能力不应被任何 method 暴露。
//
// 匹配用的是去掉 API 后缀的词根 + 词边界,而非直接子串:
// 直接子串匹配会把 tool.register / io.setToolBlocks 误判为泄露 ToolAPI
// 而那两个是合法开放的(注册自己的工具、设置自己工具的返回块)。
// 真正要拦的是形如 "tool.unregister" / "tracker.diff" 这类新增的越权 method。
forbiddenPrefixes := map[string]string{
"selftest.": "SelftestAPI",
"supervisor.": "SupervisorAPI",
"tracker.": "TrackerAPI",
"status.": "StatusAPI",
"adapter.": "AdapterAPI",
"config.": "ConfigAPI",
"indexer.": "IndexerAPI",
"outputchan.": "OutputChanRaw",
"events.publish": "EventPublish",
}
for m := range methodCapability {
lower := strings.ToLower(m)
for prefix, capName := range forbiddenPrefixes {
if strings.HasPrefix(lower, prefix) {
t.Errorf("method %q 暴露了刻意不提供的能力 %q", m, capName)
}
}
// 工具表直接操作:注册自己的工具合法,注销别人的不合法
if strings.Contains(lower, "unregister") {
t.Errorf("method %q 暴露了工具注销能力ToolAPI刻意不提供", m)
}
}
}
// KnownCapabilities 不含 core无需声明且与 methodCapability 一致。
func TestCapability_KnownListExcludesCore(t *testing.T) {
known := KnownCapabilities()
for _, k := range known {
if k == string(CapCore) {
t.Error("KnownCapabilities 不应含 core无需声明")
}
}
// 每个非 core 能力都应可声明
declared := map[string]bool{}
for _, k := range known {
declared[k] = true
}
for _, cap := range methodCapability {
if cap == CapCore {
continue
}
if !declared[string(cap)] {
t.Errorf("能力 %q 在 methodCapability 中使用但不在 KnownCapabilities 里", cap)
}
}
}
// 未知 method 走 Handle 的兜底分支,不因权限检查提前返回误导性错误。
func TestCapability_UnknownMethodFallsThrough(t *testing.T) {
h := &coreHandler{
sdk: newFakeCore(),
name: "demo",
caps: newCapabilitySet([]string{"io"}),
}
_, err := h.Handle("nonexistent.method", nil)
if err == nil {
t.Fatal("未知 method 应报错")
}
// 应是「未知 method」而非「权限被拒」——否则作者会以为是漏声明能力
if strings.Contains(err.Error(), "被拒") {
t.Errorf("未知 method 不应报权限错误,实际: %v", err)
}
}

View File

@ -0,0 +1,733 @@
package proc
import (
"context"
"encoding/json"
"fmt"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// coreHandler 把插件发来的 RPC 调用路由到内核 PluginSDK。
//
// 这是 cabi/loader.go 那 51 个 case 体的**整块平移**§3.2
// 参数解析、调用、错误处理逻辑不变,只把「整数 method id + 三个 C 字符串槽」
// 换成「method 名 + 结构化 JSON 参数」。语义不变,回归风险最小。
//
// 平移带来的直接改善:
// - 参数不再挤进 s1/s2/s3 + i1/i2 五个固定槽C ABI 的形状约束)
// - 不需要 CORE_FREE_STRING跨进程各自 GC
// - 错误可携带结构化信息,不只是一个字符串
type coreHandler struct {
// sdk 是内核为该插件构建的 PluginSDK与内置插件同一类型
sdk CoreSDK
// name 是插件名,用于工具归属推断与日志。
name string
// host 持有被全部插件共享的 StageContext 段与锁仲裁§3.3/§3.7)。
// ❗ 必须是"全部插件共享一个 Host"——每插件一段会退化成副本模型。
host *Host
// locks 是 host.locks 的引用,供 stage.lock/unlock 路由。
locks *lockRegistry
// invokeTool/invokeStageFn/invokeOutput 反向调用插件(内核 → 插件)。
// 由 Plugin 注入,注册回调时用它们构造 handler。
invokeTool func(name string, args map[string]interface{}) (interface{}, error)
invokeStageFn func(ctx context.Context, stage string, seq uint64) error
invokeOutput func(channel string, args map[string]interface{}) (interface{}, error)
// evtRing 是事件环的订阅接口(实现由 internal/plugin 提供,避免循环依赖)。
evtRing EvtRingSubscriber
// caps 是本插件被授予的能力集§3.8 权限梯度)。
// nil 或 unrestricted 时不限制——存量插件未声明 capabilities
// 若按最小权限处理会让它们静默降级。
caps *capabilitySet
}
// EvtRingSubscriber 是事件环订阅接口,由 internal/plugin.EventRing 实现。
// proc 包不依赖 internal/plugin通过接口解耦。
// EvtRingSubscribe 返回一个取消函数(与 Bus.Subscribe 约定一致)。
type EvtRingSubscriber interface {
EvtRingSubscribe(types []pubsdk.EventType) func()
}
func (h *coreHandler) invokeStageWithCtx(ctx context.Context, stage string, seq uint64) error {
if h.invokeStageFn == nil {
return fmt.Errorf("插件 %s: stage 调用通道未就绪", h.name)
}
return h.invokeStageFn(ctx, stage, seq)
}
// CoreSDK 是 coreHandler 依赖的内核能力面。
//
// 定义为接口而非直接依赖 internal/sdk.PluginSDK原因
// 1. 避免 internal/plugin/proc → internal/sdk 的强耦合(后者已依赖 internal/plugin 的类型)
// 2. 单测可注入假实现,无需构造完整内核
//
// 方法集**刻意只包含外部插件应得的能力**——`Selftest`/`Supervisor`/`Tracker`/
// `Status`/`Adapter`/`Config`/`Tool`/`Indexer`/`OutputChan`/`Publish` 不在此列。
// 这正是把权限梯度从「C ABI 表达能力的意外产物」变成「显式声明并强制的策略」§3.8)。
type CoreSDK interface {
PluginName() string
Settings() pubsdk.SettingsAPI
Memory() pubsdk.MemoryAPI
TextMemory() pubsdk.TextMemoryAPI
DocMemory() pubsdk.DocMemoryAPI
Knowledge() pubsdk.KnowledgeAPI
LLM() pubsdk.LLMAPI
Social() pubsdk.SocialAPI
PluginMgr() pubsdk.PluginMgrAPI
RegisterTool(name string, def pubsdk.ToolDef, handler pubsdk.ToolHandler) error
RegisterStage(stage pubsdk.Stage, handler pubsdk.StageHandler, scope ...pubsdk.StageScope)
RegisterPluginAPI(name string) error
RegisterOutputChannel(name string, caps int, desc string, def pubsdk.ChannelDef, handler pubsdk.ToolHandler) error
RegisterInputChannel(name string, def pubsdk.ChannelDef) error
InjectText(source, channel, text string)
InjectInterruptText(source, channel, text string)
InjectTextNoMemory(source, channel, text string)
InjectInputSync(source, channel, text string) string
SetAutoRestart(enabled bool)
}
// Handle 分派一次插件 → 内核的调用。
//
// 权限梯度在此强制§3.8manifest 未声明的能力组被**明确拒绝**。
// 不静默忽略C ABI 时代 case 23/24 返回成功但永远收不到事件
// §1.3 的「给不了」而非「不给」),插件作者无从得知。
func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}, error) {
if ok, cap := h.caps.allows(method); !ok {
return nil, errCapabilityDenied(h.name, method, cap)
}
switch method {
// ---- 注册面(原 case 1/2/3/4/46----
case MethodToolRegister:
return h.toolRegister(params)
case MethodStageRegister:
return h.stageRegister(params)
case MethodOutputRegister:
return h.outputRegister(params)
case MethodAPIRegister:
var p struct {
Name string `json:"name"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
return nil, h.sdk.RegisterPluginAPI(p.Name)
case MethodInputRegister:
var p struct {
Name string `json:"name"`
Def pubsdk.ChannelDef `json:"def"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
// 注意 ChannelDef.Cleaner 是函数无法跨进程传递§3.5 回调型资源)。
// NoMemory 可传Cleaner 若插件需要,须在插件侧对文本预处理后再注入。
return nil, h.sdk.RegisterInputChannel(p.Name, pubsdk.ChannelDef{NoMemory: p.Def.NoMemory})
// ---- IO 注入(原 case 5/6/7/47----
case MethodIOInjectText:
var p injectParams
if err := unmarshal(params, &p); err != nil {
return nil, err
}
h.sdk.InjectText(p.Source, p.Channel, p.Text)
return nil, nil
case MethodIOInjectInterrupt:
var p injectParams
if err := unmarshal(params, &p); err != nil {
return nil, err
}
h.sdk.InjectInterruptText(p.Source, p.Channel, p.Text)
return nil, nil
case MethodIOInjectTextNoMem:
var p injectParams
if err := unmarshal(params, &p); err != nil {
return nil, err
}
h.sdk.InjectTextNoMemory(p.Source, p.Channel, p.Text)
return nil, nil
case MethodIOInjectSync:
var p injectParams
if err := unmarshal(params, &p); err != nil {
return nil, err
}
return map[string]interface{}{"reply": h.sdk.InjectInputSync(p.Source, p.Channel, p.Text)}, nil
// ---- 生命周期(原 case 8----
case MethodLifecycleAutoRestart:
var p struct {
Enabled bool `json:"enabled"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
h.sdk.SetAutoRestart(p.Enabled)
return nil, nil
// ---- 图记忆(原 case 9/10/11/12/13----
case MethodMemoryRecall:
mem := h.sdk.Memory()
if mem == nil {
return nil, errUnavailable("memory")
}
var p struct {
Query []string `json:"query"`
Depth int `json:"depth"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
entities, relations, err := mem.Recall(p.Query, p.Depth)
if err != nil {
return nil, err
}
if entities == nil {
entities = []pubsdk.Entity{}
}
if relations == nil {
relations = []pubsdk.Relation{}
}
return map[string]interface{}{"entities": entities, "relations": relations}, nil
case MethodMemoryCommit:
mem := h.sdk.Memory()
if mem == nil {
return nil, errUnavailable("memory")
}
var p struct {
Triples []pubsdk.Triple `json:"triples"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
return nil, mem.Commit(p.Triples)
case MethodMemoryIntrospect:
mem := h.sdk.Memory()
if mem == nil {
return nil, errUnavailable("memory")
}
return mem.Introspect()
case MethodMemoryMerge:
mem := h.sdk.Memory()
if mem == nil {
return nil, errUnavailable("memory")
}
var p struct {
Source string `json:"source"`
Target string `json:"target"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
n, err := mem.MergeEntities(p.Source, p.Target)
if err != nil {
return nil, err
}
return map[string]interface{}{"merged": n}, nil
case MethodMemoryPurge:
mem := h.sdk.Memory()
if mem == nil {
return nil, errUnavailable("memory")
}
var p struct {
Criteria map[string]string `json:"criteria"`
Mode string `json:"mode"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
if p.Mode == "" {
p.Mode = "soft"
}
n, err := mem.Purge(p.Criteria, p.Mode)
if err != nil {
return nil, err
}
return map[string]interface{}{"purged": n}, nil
// ---- 文档记忆(原 case 14/32/33/34----
case MethodDocQuery:
dm := h.sdk.DocMemory()
if dm == nil {
return nil, errUnavailable("doc memory")
}
var p struct {
Text string `json:"text"`
TopK int `json:"top_k"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
docs := dm.Query(p.Text, p.TopK)
if docs == nil {
docs = []*pubsdk.Doc{}
}
return map[string]interface{}{"docs": docs}, nil
case MethodDocInsert:
dm := h.sdk.DocMemory()
if dm == nil {
return nil, errUnavailable("doc memory")
}
var p struct {
Doc *pubsdk.Doc `json:"doc"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
if p.Doc == nil {
return nil, fmt.Errorf("doc.insert: 缺少 doc 字段")
}
return nil, dm.Insert(p.Doc)
case MethodDocRemove:
dm := h.sdk.DocMemory()
if dm == nil {
return nil, errUnavailable("doc memory")
}
var p struct {
ID string `json:"id"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
dm.Remove(p.ID)
return nil, nil
case MethodDocStats:
dm := h.sdk.DocMemory()
if dm == nil {
return nil, errUnavailable("doc memory")
}
return dm.Stats(), nil
// ---- 知识库(原 case 15/35/36----
case MethodKnowledgeSearch:
kn := h.sdk.Knowledge()
if kn == nil {
return nil, errUnavailable("knowledge")
}
var p struct {
Query string `json:"query"`
TopK int `json:"top_k"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
results, err := kn.Search(p.Query, p.TopK)
if err != nil {
return nil, err
}
if results == nil {
results = []*pubsdk.Knowledge{}
}
return map[string]interface{}{"results": results}, nil
case MethodKnowledgeAdd:
kn := h.sdk.Knowledge()
if kn == nil {
return nil, errUnavailable("knowledge")
}
var p struct {
Name string `json:"name"`
Content string `json:"content"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
return nil, kn.Add(p.Name, p.Content)
case MethodKnowledgeList:
kn := h.sdk.Knowledge()
if kn == nil {
return nil, errUnavailable("knowledge")
}
names, err := kn.List()
if err != nil {
return nil, err
}
if names == nil {
names = []string{}
}
return map[string]interface{}{"names": names}, nil
// ---- 文本记忆(原 case 41----
case MethodTextMemoryAppend:
tm := h.sdk.TextMemory()
if tm == nil {
return nil, errUnavailable("text memory")
}
var p struct {
Event pubsdk.TextEvent `json:"event"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
return nil, tm.Append(p.Event)
// ---- 设置(原 case 16/17/18/26~31/42~45/51----
case MethodSettingsGet, MethodSettingsSet, MethodSettingsRegisterDef,
MethodSettingsGetCore, MethodSettingsSetCore, MethodSettingsListCore,
MethodSettingsGetPlugin, MethodSettingsSetPlugin, MethodSettingsListPlugin,
MethodSettingsList, MethodSettingsDefs, MethodSettingsDump,
MethodSettingsPlugins, MethodSettingsDataDir:
return h.settings(method, params)
// ---- LLM 源(原 case 19/20/37----
case MethodLLMListSources:
llm := h.sdk.LLM()
if llm == nil {
return nil, errUnavailable("llm")
}
sources := llm.ListSources()
if sources == nil {
sources = []string{}
}
return map[string]interface{}{"sources": sources}, nil
case MethodLLMSetSource:
llm := h.sdk.LLM()
if llm == nil {
return nil, errUnavailable("llm")
}
var p struct {
Name string `json:"name"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
return nil, llm.SetSource(p.Name)
case MethodLLMCurrentSource:
llm := h.sdk.LLM()
if llm == nil {
return nil, errUnavailable("llm")
}
return map[string]interface{}{"source": llm.CurrentSource()}, nil
// ---- 社交图(只读,原 case 21/22/38/39/40----
case MethodSocialGetPerson, MethodSocialGetNetwork, MethodSocialGetTrait,
MethodSocialGetRelation, MethodSocialListPersons:
return h.social(method, params)
// ---- 插件管理(原 case 48/49/50----
case MethodPluginReloadOne:
pm := h.sdk.PluginMgr()
if pm == nil {
return nil, errUnavailable("plugin manager")
}
var p struct {
Name string `json:"name"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
return nil, pm.ReloadOne(p.Name)
case MethodPluginListLoaded:
pm := h.sdk.PluginMgr()
if pm == nil {
return nil, errUnavailable("plugin manager")
}
list := pm.ListLoadedPlugins()
if list == nil {
list = []string{}
}
return map[string]interface{}{"plugins": list}, nil
case MethodPluginIsDisabled:
pm := h.sdk.PluginMgr()
if pm == nil {
return nil, errUnavailable("plugin manager")
}
var p struct {
Name string `json:"name"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
return map[string]interface{}{"disabled": pm.IsPluginDisabled(p.Name)}, nil
// ---- 共享段锁仲裁新增§3.7----
case MethodStageLock:
if h.locks == nil {
return nil, fmt.Errorf("stage.lock: 锁仲裁未就绪")
}
return nil, h.locks.acquire(h.name)
case MethodStageUnlock:
if h.locks == nil {
return nil, fmt.Errorf("stage.unlock: 锁仲裁未就绪")
}
return nil, h.locks.release(h.name)
// ---- 事件订阅(原 case 23/24子进程下首次真正可用§3.6----
case MethodEventsSubscribe:
var p struct {
Types []pubsdk.EventType `json:"types"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
if h.evtRing == nil {
return nil, fmt.Errorf("%s: 事件环未就绪", method)
}
// 订阅请求来自子进程——handler 直接注册到 Bus
// 事件经 EventRing 写入环后由子进程消费。
h.evtRing.EvtRingSubscribe(p.Types)
return nil, nil
case MethodEventsUnsubscribe:
// 事件环的订阅没有持久化句柄(取消函数由 Subscribe 返回但子进程未保存)。
// 当前设计:子进程 Stop 时由内核统一清理其订阅。
return nil, nil
// ---- 多模态注入C ABI 侧空实现)----
case MethodIOSetToolBlocks:
// Part 4 扩展:二进制落 arena、Slice 描述符回传§3.8)。
return nil, fmt.Errorf("%s: 多模态注入待共享段二进制通道落地", method)
}
return nil, fmt.Errorf("未知 method: %s", method)
}
type injectParams struct {
Source string `json:"source"`
Channel string `json:"channel"`
Text string `json:"text"`
}
func unmarshal(params json.RawMessage, out interface{}) error {
if len(params) == 0 {
return nil
}
if err := json.Unmarshal(params, out); err != nil {
return fmt.Errorf("参数解析失败: %w", err)
}
return nil
}
func errUnavailable(what string) error {
return fmt.Errorf("%s 能力在当前内核实例中不可用", what)
}
// toolRegister 注册插件工具handler 反向调用插件执行(原 case 1
func (h *coreHandler) toolRegister(params json.RawMessage) (interface{}, error) {
var p struct {
Name string `json:"name"`
Def pubsdk.ToolDef `json:"def"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
if p.Name == "" {
return nil, fmt.Errorf("tool.register: 缺少 name")
}
p.Def.Plugin = h.name
// ToolDef.Cleaner 是函数跨进程无法传递§3.5)——与 C ABI 路径行为一致。
p.Def.Cleaner = nil
name := p.Name
return nil, h.sdk.RegisterTool(name, p.Def, func(args map[string]interface{}) (interface{}, error) {
return h.invokeTool(name, args)
})
}
// stageRegister 注册阶段处理器(原 case 2
//
// **与 C ABI 路径的本质差异**:这里不做「快照 → 副本 → 写回」。
// StageContext 的数据在共享段,插件直接在同一份状态上读改写,
// 由锁仲裁串行化——消除了副本模型的 lost update§8.4 实测 35.8~36.8%)。
func (h *coreHandler) stageRegister(params json.RawMessage) (interface{}, error) {
var p struct {
Stage string `json:"stage"`
Scope string `json:"scope"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
if p.Stage == "" {
return nil, fmt.Errorf("stage.register: 缺少 stage")
}
scope := pubsdk.StageScopeGlobal
if p.Scope == "own_tools" {
scope = pubsdk.StageScopeOwnTools
}
stage := p.Stage
h.sdk.RegisterStage(pubsdk.Stage(stage), func(sc *pubsdk.StageContext) error {
return h.runStage(stage, sc)
}, scope)
return nil, nil
}
// outputRegister 注册输出通道(原 case 3
//
// **与 C ABI 路径的本质差异**:可同步等真实结果。
// C ABI 下因 cgo 不可嵌套,只能异步 fire-and-forget导致 output_send
// 永远返回成功§9.4,现网 2 次消息发不出而模型以为成功)。
func (h *coreHandler) outputRegister(params json.RawMessage) (interface{}, error) {
var p struct {
Name string `json:"name"`
Caps int `json:"caps"`
Desc string `json:"desc"`
Def pubsdk.ChannelDef `json:"def"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
if p.Name == "" {
return nil, fmt.Errorf("output.register: 缺少 name")
}
channel := p.Name
return nil, h.sdk.RegisterOutputChannel(channel, p.Caps, p.Desc,
pubsdk.ChannelDef{NoMemory: p.Def.NoMemory},
func(args map[string]interface{}) (interface{}, error) {
return h.invokeOutput(channel, args)
})
}
func (h *coreHandler) settings(method string, params json.RawMessage) (interface{}, error) {
sett := h.sdk.Settings()
if sett == nil {
return nil, errUnavailable("settings")
}
var p struct {
Key string `json:"key"`
Value interface{} `json:"value"`
Prefix string `json:"prefix"`
Plugin string `json:"plugin"`
Def pubsdk.ConfigDef `json:"def"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
switch method {
case MethodSettingsGet:
v, err := sett.Get(p.Key)
if err != nil {
return nil, err
}
return map[string]interface{}{"value": v}, nil
case MethodSettingsSet:
return nil, sett.Set(p.Key, p.Value)
case MethodSettingsRegisterDef:
sett.RegisterDef(p.Def)
return nil, nil
case MethodSettingsGetCore:
v, err := sett.GetCore(p.Key)
if err != nil {
return nil, err
}
return map[string]interface{}{"value": v}, nil
case MethodSettingsSetCore:
return nil, sett.SetCore(p.Key, p.Value)
case MethodSettingsListCore:
keys, err := sett.ListCore(p.Prefix)
if err != nil {
return nil, err
}
return map[string]interface{}{"keys": orEmpty(keys)}, nil
case MethodSettingsGetPlugin:
v, err := sett.GetPlugin(p.Plugin, p.Key)
if err != nil {
return nil, err
}
return map[string]interface{}{"value": v}, nil
case MethodSettingsSetPlugin:
return nil, sett.SetPlugin(p.Plugin, p.Key, p.Value)
case MethodSettingsListPlugin:
keys, err := sett.ListPlugin(p.Plugin, p.Prefix)
if err != nil {
return nil, err
}
return map[string]interface{}{"keys": orEmpty(keys)}, nil
case MethodSettingsList:
keys, err := sett.List(p.Prefix)
if err != nil {
return nil, err
}
return map[string]interface{}{"keys": orEmpty(keys)}, nil
case MethodSettingsDefs:
defs := sett.Defs(p.Prefix)
if defs == nil {
defs = []*pubsdk.ConfigDef{}
}
return map[string]interface{}{"defs": defs}, nil
case MethodSettingsDump:
return sett.Dump(), nil
case MethodSettingsPlugins:
return map[string]interface{}{"plugins": orEmpty(sett.Plugins())}, nil
case MethodSettingsDataDir:
return map[string]interface{}{"dir": sett.DataDir()}, nil
}
return nil, fmt.Errorf("未知 settings method: %s", method)
}
func (h *coreHandler) social(method string, params json.RawMessage) (interface{}, error) {
social := h.sdk.Social()
if social == nil {
return nil, errUnavailable("social")
}
var p struct {
Name string `json:"name"`
Trait string `json:"trait"`
Depth int `json:"depth"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
switch method {
case MethodSocialGetPerson:
profile, err := social.GetPerson(p.Name)
if err != nil {
return nil, err
}
return map[string]interface{}{"person": profile}, nil
case MethodSocialGetNetwork:
profiles, err := social.GetNetwork(p.Name, p.Depth)
if err != nil {
return nil, err
}
if profiles == nil {
profiles = []*pubsdk.PersonProfile{}
}
return map[string]interface{}{"network": profiles}, nil
case MethodSocialGetTrait:
v, ok := social.GetTrait(p.Name, p.Trait)
return map[string]interface{}{"value": v, "found": ok}, nil
case MethodSocialGetRelation:
rels, err := social.GetRelations(p.Name)
if err != nil {
return nil, err
}
if rels == nil {
rels = []pubsdk.SocialRelation{}
}
return map[string]interface{}{"relations": rels}, nil
case MethodSocialListPersons:
names, err := social.ListPersons()
if err != nil {
return nil, err
}
return map[string]interface{}{"persons": orEmpty(names)}, nil
}
return nil, fmt.Errorf("未知 social method: %s", method)
}
func orEmpty(s []string) []string {
if s == nil {
return []string{}
}
return s
}

View File

@ -0,0 +1,275 @@
package proc
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// 端到端:用**真实 plugindev 模板**编译的插件,经内核 proc 通道加载运行。
//
// 与 plugin_test.go 中 testdata/*.go 假插件的区别:
// 那些是手写的最简 RPC 实现,只验证内核侧逻辑;
// 这里用的是 tools/plugindev/templates/proc_main.go.tmpl —— 外部插件作者
// 真正会拿到的那份运行时。它验证的是「模板 ↔ 内核」两侧协议/布局真的对齐,
// 而不只是内核自己跟自己对齐。
//
// 插件业务代码只用公开 SDKNewPluginFactory + sdk.PluginSDK与 .so 时代一致。
const e2ePluginSource = `package main
import (
"strings"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type e2ePlugin struct{ name string }
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &e2ePlugin{name: name}, nil
}
func (p *e2ePlugin) Name() string { return p.name }
func (p *e2ePlugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
s.RegisterTool("e2e_echo", sdk.ToolDef{
Description: "回显",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"text": map[string]interface{}{"type": "string"},
},
},
}, func(args map[string]interface{}) (interface{}, error) {
t, _ := args["text"].(string)
return "echo:" + t, nil
})
s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
for i := range ctx.ToolResults {
if str, ok := ctx.ToolResults[i].Result.(string); ok {
ctx.ToolResults[i].Result = strings.ReplaceAll(str, "脏", "净")
}
}
// FinalText 在 C ABI 下对 after_toolcall 不可见§8.310 → 16 字段)
ctx.FinalText = ctx.FinalText + "|stage-touched"
return nil
})
return nil
}
func (p *e2ePlugin) Stop() error { return nil }
`
// procRuntimeTemplates 列出 plugindev 会生成到插件目录的运行时文件。
//
// 必须与 SDK 仓 tools/plugindev/proc_runtime.go 的 procRuntimeFiles 一致:
// 共享段与事件通知的传递机制按平台不同Unix 继承 fdWindows 命名
// 内核对象),故拆成带 build tag 的文件;只写主模板会编译失败。
var procRuntimeTemplates = []struct {
tmpl string
out string
}{
{"proc_main.go.tmpl", "z_proc_gen.go"},
{"proc_shm_unix.go.tmpl", "z_proc_shm_unix.go"},
{"proc_shm_windows.go.tmpl", "z_proc_shm_windows.go"},
}
// buildPluginWithRealTemplate 用 plugindev 的真实模板编译一个插件二进制。
func buildPluginWithRealTemplate(t *testing.T, businessCode string) string {
t.Helper()
if _, err := exec.LookPath("go"); err != nil {
t.Skip("环境无 go 工具链,跳过端到端测试")
}
tmplDir := filepath.Join("..", "..", "..",
"third_party", "homeagent-sdk", "tools", "plugindev", "templates")
dir := t.TempDir()
mustWriteFile(t, filepath.Join(dir, "plugin.go"), businessCode)
for _, rt := range procRuntimeTemplates {
data, err := os.ReadFile(filepath.Join(tmplDir, rt.tmpl))
if err != nil {
t.Skipf("plugindev 模板 %s 不可读SDK 仓可能未就位): %v", rt.tmpl, err)
}
mustWriteFile(t, filepath.Join(dir, rt.out), string(data))
}
sdkPath, err := filepath.Abs(filepath.Join("..", "..", "..", "third_party", "homeagent-sdk"))
if err != nil {
t.Fatalf("解析 SDK 路径: %v", err)
}
mustWriteFile(t, filepath.Join(dir, "go.mod"),
"module e2eplugin\n\ngo 1.25\n\n"+
"require gitcode.com/JianFeeeee/homeagent-sdk v0.9.2\n\n"+
"replace gitcode.com/JianFeeeee/homeagent-sdk => "+sdkPath+"\n")
bin := filepath.Join(dir, "plugin.bin")
cmd := exec.Command("go", "build", "-o", bin, ".")
cmd.Dir = dir
// CGO_ENABLED=0模板零 cgo 是迁移的核心收益,这里同时充当回归保护
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("用真实模板编译插件失败: %v\n%s", err, out)
}
return bin
}
func mustWriteFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("写 %s: %v", path, err)
}
}
// 完整链路:真实模板编译 → spawn → 握手 → init/start → 反向注册 → 工具调用 → stage 读改写。
func TestE2E_RealTemplatePluginFullLifecycle(t *testing.T) {
bin := buildPluginWithRealTemplate(t, e2ePluginSource)
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
core := newFakeCore()
p := New("e2e", bin, t.TempDir(), nil, host, nil)
if err := p.Start(core); err != nil {
t.Fatalf("Start: %v", err)
}
defer p.Close()
// 1) SetAutoRestart 必须经 lifecycle.autoRestart 上报到内核。
// 公开 SDK 的 SetAutoRestart 是纯 setter无 hook插件在 Start() 里
// 调它只改自己进程内的副本;模板须在 Start 返回后显式上报一次。
if !core.autoStart {
t.Error("插件的 SetAutoRestart(true) 未传达到内核(模板漏了 lifecycle.autoRestart 上报?)")
}
// 2) 工具注册与调用
core.mu.Lock()
toolHandler, hasTool := core.tools["e2e_echo"]
core.mu.Unlock()
if !hasTool {
t.Fatal("插件注册的工具未到达内核")
}
res, err := toolHandler(map[string]interface{}{"text": "你好"})
if err != nil {
t.Fatalf("调用插件工具: %v", err)
}
if got, _ := res.(string); got != "echo:你好" {
t.Errorf("工具返回 %q期望 echo:你好", got)
}
// 3) stage 读改写经共享段回到内核 StageContext
handlers := core.stageHandlers(pubsdk.StageAfterToolcall)
if len(handlers) != 1 {
t.Fatalf("应注册 1 个 after_toolcall handler实际 %d", len(handlers))
}
sc := &pubsdk.StageContext{
Phase: pubsdk.StageAfterToolcall,
FinalText: "原文",
ToolResults: []pubsdk.ToolResult{{CallID: "c1", Name: "t", Result: "这是脏数据"}},
}
if errs := runStageLikeKernel(handlers, sc); len(errs) > 0 {
t.Fatalf("stage 执行失败: %v", errs)
}
got, _ := sc.ToolResults[0].Result.(string)
if got != "这是净数据" {
t.Errorf("清洗结果未回到内核 StageContext实际 %q", got)
}
// FinalText 在 C ABI 的 after_toolcall 下根本看不到(只下发 10 字段中的一部分)
if sc.FinalText != "原文|stage-touched" {
t.Errorf("FinalText 改写未回传:实际 %qC ABI 下此字段在本阶段不可见)", sc.FinalText)
}
}
// 只读插件与改写插件并发时,改写结果不被覆盖。
//
// 这是本次迁移最关键的性质,用**真实模板**再验一次:
// 字段级脏写入使只读插件的写入集为空,物理上不可能覆盖他人改写。
// 对照 C ABI 副本模型实测 35.8~36.8% lost update§8.4)。
func TestE2E_RealTemplateReadOnlyPluginDoesNotOverwrite(t *testing.T) {
const readerSource = `package main
import sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
type readerPlugin struct{ name string }
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &readerPlugin{name: name}, nil
}
func (p *readerPlugin) Name() string { return p.name }
func (p *readerPlugin) Start(s *sdk.PluginSDK) error {
// 只读:遍历但不改任何字段
s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
for range ctx.ToolResults {
}
_ = ctx.FinalText
return nil
})
return nil
}
func (p *readerPlugin) Stop() error { return nil }
`
writerBin := buildPluginWithRealTemplate(t, e2ePluginSource)
readerBin := buildPluginWithRealTemplate(t, readerSource)
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
core := newFakeCore()
// 两个插件共享同一 Host= 同一 memfd
// 若每插件一块段,这里就会退化成副本模型,本测试必然失败。
writer := New("writer", writerBin, t.TempDir(), nil, host, nil)
if err := writer.Start(core); err != nil {
t.Fatalf("writer.Start: %v", err)
}
defer writer.Close()
reader := New("reader", readerBin, t.TempDir(), nil, host, nil)
if err := reader.Start(core); err != nil {
t.Fatalf("reader.Start: %v", err)
}
defer reader.Close()
handlers := core.stageHandlers(pubsdk.StageAfterToolcall)
if len(handlers) != 2 {
t.Fatalf("应有 2 个 after_toolcall handler实际 %d", len(handlers))
}
sc := &pubsdk.StageContext{
Phase: pubsdk.StageAfterToolcall,
FinalText: "原文",
ToolResults: []pubsdk.ToolResult{{CallID: "c1", Name: "t", Result: "这是脏数据"}},
}
if errs := runStageLikeKernel(handlers, sc); len(errs) > 0 {
t.Fatalf("stage 执行失败: %v", errs)
}
got, _ := sc.ToolResults[0].Result.(string)
if got != "这是净数据" {
t.Fatalf("只读插件覆盖了改写插件的结果lost update实际 %q", got)
}
if !strings.Contains(sc.FinalText, "stage-touched") {
t.Errorf("FinalText 改写被覆盖:实际 %q", sc.FinalText)
}
}

View File

@ -0,0 +1,81 @@
//go:build darwin
package proc
import (
"os"
"sync"
"syscall"
)
// macOS 侧事件通知:用 pipe 模拟 eventfdmacOS 无 eventfd_create
//
// 与 eventfd 的语义差异pipe 不具计数合并,多次写会触发多次读。
// 这不影响正确性——消费者在 drainEvents 里按 readSeq 追 writeSeq 批量读,
// 多次唤醒只多几次空循环readSeq == writeSeq 时立即返回)。
//
// 走 Go netpolleros.File.Read 阻塞时只 park goroutine不占 OS 线程
// (实验 1 已验证 200 个等待者仅增 1 个 OS 线程)。
//
// ❗ 必须同时持有读端与写端:写端若被 GC 回收,读端会收到 EOF 而非阻塞,
// 消费循环变成忙转。故用 pipePair 表存住两端。
type pipePair struct {
r *os.File
w *os.File
}
var (
evtPipes = map[int]*pipePair{}
evtPipesMu sync.Mutex
)
// evtfdCreate 建 pipe返回读端 fd。
func evtfdCreate() (int, error) {
r, w, err := os.Pipe()
if err != nil {
return -1, err
}
fd := int(r.Fd())
evtPipesMu.Lock()
evtPipes[fd] = &pipePair{r: r, w: w}
evtPipesMu.Unlock()
return fd, nil
}
// EvtfdNotify 写 1 字节通知子进程有新事件post-and-forget
//
// 直接写裸 fd 而非 pipePair.w本函数在 Bus.Publish 路径上被高频调用,
// 查表加锁不值得。写端 fd 由 pipePair 持有引用故不会被 GC 回收。
func EvtfdNotify(efd int) {
evtPipesMu.Lock()
p, ok := evtPipes[efd]
evtPipesMu.Unlock()
if !ok {
return
}
var buf [1]byte
// 忽略错误:管道满说明消费者落后,事件环本身允许溢出丢弃
syscall.Write(int(p.w.Fd()), buf[:])
}
// evtfdReadFile 返回通知读端(供 netpoller 消费)。
func evtfdReadFile(efd int) *os.File {
evtPipesMu.Lock()
defer evtPipesMu.Unlock()
if p, ok := evtPipes[efd]; ok {
return p.r
}
return nil
}
// evtfdClose 关闭 pipe 两端。
func evtfdClose(efd int) {
evtPipesMu.Lock()
p, ok := evtPipes[efd]
delete(evtPipes, efd)
evtPipesMu.Unlock()
if ok {
p.r.Close()
p.w.Close()
}
}

View File

@ -0,0 +1,38 @@
//go:build linux
package proc
import (
"os"
"syscall"
"golang.org/x/sys/unix"
)
// evtfdCreate 创建 Linux eventfdEFD_NONBLOCK | EFD_CLOEXEC
//
// 语义64 位无符号计数器,多次 Write(8) 只累加Read 一次取出合并值。
// 计数合并满足 §3.6 的设计1000 个 token 事件只唤醒几次。
// 走 Go netpoller实验 1 已验证 200 等待者仅 +1 OS 线程)。
func evtfdCreate() (int, error) {
return unix.Eventfd(0, unix.EFD_CLOEXEC)
}
// evtfdNotify 写 1 到 eventfd 通知子进程有新事件post-and-forget
//
// EFD_NONBLOCK 保证不阻塞§3.6 约束 BBus.Publish 路径上绝不等待)。
// 计数语义使多事件写入合并成一次唤醒。
func EvtfdNotify(efd int) {
var buf [8]byte
buf[0] = 1
// 忽略错误EFD_NONBLOCK 下只有内存不足才会失败,此时进程已在崩溃边缘
syscall.Write(efd, buf[:])
}
// evtfdReadFile 把 eventfd 包装成 *os.File 供 netpoller 消费。
func evtfdReadFile(efd int) *os.File {
return os.NewFile(uintptr(efd), "evtring-notify")
}
// evtfdClose 关闭通知句柄。Unix 侧由 *os.File.Close 负责,此处为跨平台签名占位。
func evtfdClose(efd int) {}

View File

@ -0,0 +1,21 @@
//go:build !linux && !darwin && !windows
package proc
// evtfdCreateWindows 不支持 eventfd 和 pipe 事件环§9.2)。
func evtfdCreate() (int, error) {
return -1, errPlatformNotSupported("eventfd")
}
func EvtfdNotify(efd int) {}
func evtfdReadFile(efd int) interface{} { return nil }
type errPlatformNotSupported string
func (e errPlatformNotSupported) Error() string {
return "当前平台尚未支持事件环通知(" + string(e) + "§9.2"
}
// evtfdClose 关闭通知句柄。Unix 侧由 *os.File.Close 负责,此处为跨平台签名占位。
func evtfdClose(efd int) {}

View File

@ -0,0 +1,103 @@
//go:build windows
package proc
import (
"fmt"
"os"
"sync"
"sync/atomic"
"golang.org/x/sys/windows"
)
// Windows 侧事件通知:命名 Event 对象。
//
// 与 eventfd 的语义差异Event 是二元信号Set/Reset不是计数器。
// 多次 SetEvent 只对应一次唤醒,不会累积。
//
// 这不影响正确性:消费者被唤醒后按 readSeq 追 writeSeq 批量 drain
// 一次唤醒能处理累积的全部事件(漏掉的是"唤醒次数",不是"事件")。
// 事件环本身允许溢出丢弃并让消费者知道丢了dropped 计数),
// 通知面从来不是可靠投递语义。
//
// 代价WaitForSingleObject 阻塞 OS 线程而非仅 goroutine不如 eventfd
// 的 netpoller 路径省线程。每插件一个消费 goroutine17 插件即 17 线程
// (实验 5 实测 17 子进程共 84 线程,仍在可接受范围)。
var evtEventSeq atomic.Uint64
// evtEventHandles 记录 fd 伪值 → Event 句柄的映射。
//
// 为何需要:跨平台签名用 int 表示通知句柄Unix 是真 fd
// Windows 的 windows.Handle 是 uintptr直接转 int 在 32 位上会截断,
// 故用递增伪 fd 做 key句柄存表里。
var (
evtEvents = map[int]windows.Handle{}
evtEventsMu sync.Mutex
evtEventFd atomic.Int64
)
// evtfdCreate 创建命名 Event 对象,返回伪 fd。
//
// 手动重置manualReset=false → 自动重置):被一个等待者唤醒后自动 Reset
// 语义最接近 eventfd 的"取出后清零"。
func evtfdCreate() (int, error) {
name := fmt.Sprintf("%s_%d_%d", evtEventNamePfx, os.Getpid(), evtEventSeq.Add(1))
namePtr, err := windows.UTF16PtrFromString(name)
if err != nil {
return -1, fmt.Errorf("proc: 事件对象名字非法 %q: %w", name, err)
}
h, err := windows.CreateEvent(nil, 0 /*autoReset*/, 0 /*initiallyNonSignaled*/, namePtr)
if err != nil {
return -1, fmt.Errorf("proc: 创建事件对象 %q: %w", name, err)
}
fd := int(evtEventFd.Add(1))
evtEventsMu.Lock()
evtEvents[fd] = h
evtEventNames[fd] = name
evtEventsMu.Unlock()
return fd, nil
}
// evtEventNames 记录伪 fd → 对象名(供注入子进程环境变量)。
var evtEventNames = map[int]string{}
// EvtfdNotify 唤醒等待者post-and-forget
//
// SetEvent 不阻塞,满足 §3.6 约束 BBus.Publish 路径上绝不等待)。
func EvtfdNotify(efd int) {
evtEventsMu.Lock()
h, ok := evtEvents[efd]
evtEventsMu.Unlock()
if !ok {
return
}
// 忽略错误:句柄有效时 SetEvent 不会失败
windows.SetEvent(h)
}
// evtfdReadFile 在 Windows 上返回 nil。
//
// 内核侧不消费事件环(只写入),消费在插件进程里由模板的
// windowsEvtWaiter 完成。这个函数只为跨平台签名存在。
func evtfdReadFile(efd int) *os.File { return nil }
// evtEventNameOf 返回某个伪 fd 对应的 Event 对象名(供注入子进程环境变量)。
func evtEventNameOf(efd int) string {
evtEventsMu.Lock()
defer evtEventsMu.Unlock()
return evtEventNames[efd]
}
// evtfdClose 关闭 Event 句柄。
func evtfdClose(efd int) {
evtEventsMu.Lock()
h, ok := evtEvents[efd]
delete(evtEvents, efd)
delete(evtEventNames, efd)
evtEventsMu.Unlock()
if ok {
windows.CloseHandle(h)
}
}

View File

@ -0,0 +1,277 @@
package proc
import (
"encoding/binary"
"encoding/json"
"fmt"
"os"
"sync"
"sync/atomic"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// ---- 事件类型编码(编译时确定,与 pubsdk.EventType 一一对应)----
var evtTypeNames = [evtTypeMax]string{
"raw_input",
"agent_output",
"agent_llm_chain",
"tool_call",
"reasoning",
"stage",
"system",
"reasoning_delta",
"content_delta",
"skill_detected",
}
var evtTypeIndex = map[string]uint32{
"raw_input": evtTypeRawInput,
"agent_output": evtTypeAgentOutput,
"agent_llm_chain": evtTypeAgentLLMChain,
"tool_call": evtTypeToolCall,
"reasoning": evtTypeReasoning,
"stage": evtTypeStage,
"system": evtTypeSystem,
"reasoning_delta": evtTypeReasoningDelta,
"content_delta": evtTypeContentDelta,
"skill_detected": evtTypeSkillDetected,
}
func encodeEvtType(t pubsdk.EventType) uint32 {
if idx, ok := evtTypeIndex[string(t)]; ok {
return idx
}
return 0xFFFFFFFF // 未知类型:子进程 typeMask 用 0 匹配全部,此值不影响
}
func decodeEvtType(idx uint32) pubsdk.EventType {
if int(idx) < len(evtTypeNames) {
return pubsdk.EventType(evtTypeNames[idx])
}
return ""
}
func evtTypeMask(types ...pubsdk.EventType) uint32 {
var mask uint32
for _, t := range types {
if idx, ok := evtTypeIndex[string(t)]; ok {
mask |= 1 << idx
}
}
return mask
}
// ---- 事件环共享段布局§3.6----
const (
evtRingMagic uint32 = 0x48455654 // "HEVT"
evtRingVersion uint32 = 1
evtRingCap uint32 = 8192 // 2^13满足流式场景突发实验 4
evtRingSlotLen uint32 = 32 // seq(8)+type(4)+off(4)+len(4)+pad(12)
evtOffMagic uint32 = 0
evtOffVersion uint32 = 4
evtOffWriteSeq uint32 = 8
evtOffCap uint32 = 16
evtOffSlots uint32 = 20
evtTypeRawInput uint32 = 0
evtTypeAgentOutput uint32 = 1
evtTypeAgentLLMChain uint32 = 2
evtTypeToolCall uint32 = 3
evtTypeReasoning uint32 = 4
evtTypeStage uint32 = 5
evtTypeSystem uint32 = 6
evtTypeReasoningDelta uint32 = 7
evtTypeContentDelta uint32 = 8
evtTypeSkillDetected uint32 = 9
evtTypeMax uint32 = 10
evtHeaderSize = 20
evtArenaCap = 64 * 1024
evtTotalSize = int(evtHeaderSize + evtRingCap*evtRingSlotLen + evtArenaCap)
)
// ---- 内核侧EvtRing ----
type EvtRing struct {
data []byte
writeSeq atomic.Uint64
cap uint32
slotsBase uint32
arenaBase uint32
arenaCap uint32
arenaUsed atomic.Uint32
mu sync.Mutex
}
func NewEvtRing(data []byte) (*EvtRing, error) {
if uint32(len(data)) < evtHeaderSize+evtRingCap*evtRingSlotLen+evtArenaCap {
return nil, fmt.Errorf("事件环段太小:需要 %d实际 %d", evtTotalSize, len(data))
}
if got := binary.LittleEndian.Uint32(data[evtOffMagic:]); got != evtRingMagic {
return nil, fmt.Errorf("事件环魔数不匹配0x%x", got)
}
return &EvtRing{
data: data,
cap: evtRingCap,
slotsBase: evtOffSlots,
arenaBase: evtOffSlots + evtRingCap*evtRingSlotLen,
arenaCap: evtArenaCap,
}, nil
}
// allocEvtRing 创建事件环共享段memfd + mmap返回 (段句柄, mmap数据, eventfd fd)。
// 段句柄通过 ExtraFiles 传给子进程fd 4eventfdfd 5也通过 ExtraFiles 传。
func allocEvtRing() (*os.File, []byte, int, error) {
ringfd, ringData, err := allocShm(evtTotalSize)
if err != nil {
return nil, nil, -1, fmt.Errorf("创建事件环段: %w", err)
}
// 初始化头
binary.LittleEndian.PutUint32(ringData[evtOffMagic:], evtRingMagic)
binary.LittleEndian.PutUint32(ringData[evtOffVersion:], evtRingVersion)
binary.LittleEndian.PutUint32(ringData[evtOffCap:], evtRingCap)
efd, err := evtfdCreate()
if err != nil {
freeShm(ringfd, ringData)
return nil, nil, -1, fmt.Errorf("创建 eventfd: %w", err)
}
return ringfd, ringData, efd, nil
}
func (r *EvtRing) Init() {
binary.LittleEndian.PutUint32(r.data[evtOffMagic:], evtRingMagic)
binary.LittleEndian.PutUint32(r.data[evtOffVersion:], evtRingVersion)
binary.LittleEndian.PutUint32(r.data[evtOffCap:], r.cap)
r.writeSeq.Store(0)
}
// WritePush post-and-forget**绝不阻塞**§3.6 约束 B
func (r *EvtRing) WritePush(evtType pubsdk.EventType, payload []byte) {
seq := r.writeSeq.Add(1) - 1
var off uint32
r.mu.Lock()
used := r.arenaUsed.Load()
if used+uint32(len(payload)) <= r.arenaCap {
off = r.arenaBase + used
r.arenaUsed.Store(used + uint32(len(payload)))
copy(r.data[off:], payload)
}
r.mu.Unlock()
idx := seq % uint64(r.cap)
slotOff := r.slotsBase + uint32(idx)*evtRingSlotLen
binary.LittleEndian.PutUint64(r.data[slotOff:], seq)
binary.LittleEndian.PutUint32(r.data[slotOff+8:], encodeEvtType(evtType))
binary.LittleEndian.PutUint32(r.data[slotOff+12:], off)
binary.LittleEndian.PutUint32(r.data[slotOff+16:], uint32(len(payload)))
binary.LittleEndian.PutUint64(r.data[evtOffWriteSeq:], seq+1)
}
// ---- 子进程侧EvtConsumer ----
type EvtConsumer struct {
ringData []byte
evtfd evtfdReader
handler func(*pubsdk.Event) error
readSeq uint64
typeMask uint32
mu sync.Mutex
running bool
stop chan struct{}
}
type evtfdReader interface {
Read(b []byte) (int, error)
}
func NewEvtConsumer(ringData []byte, evtfd evtfdReader, mask uint32, handler func(*pubsdk.Event) error) *EvtConsumer {
return &EvtConsumer{
ringData: ringData,
evtfd: evtfd,
handler: handler,
typeMask: mask,
stop: make(chan struct{}),
}
}
func (c *EvtConsumer) Run() {
c.mu.Lock()
if c.running {
c.mu.Unlock()
return
}
c.running = true
c.mu.Unlock()
defer func() {
c.mu.Lock()
c.running = false
c.mu.Unlock()
}()
buf := make([]byte, 8)
for {
select {
case <-c.stop:
return
default:
}
// 阻塞等待内核通知(走 netpoller只 park goroutine
if _, err := c.evtfd.Read(buf); err != nil {
continue
}
c.drainEvents()
}
}
func (c *EvtConsumer) drainEvents() {
writeSeq := binary.LittleEndian.Uint64(c.ringData[evtOffWriteSeq:])
cap := uint64(evtRingCap)
for c.readSeq < writeSeq {
if writeSeq-c.readSeq > cap {
c.readSeq = writeSeq - cap
}
idx := c.readSeq % cap
slotOff := evtOffSlots + uint32(idx)*evtRingSlotLen
seq := binary.LittleEndian.Uint64(c.ringData[slotOff:])
etype := binary.LittleEndian.Uint32(c.ringData[slotOff+8:])
off := binary.LittleEndian.Uint32(c.ringData[slotOff+12:])
slen := binary.LittleEndian.Uint32(c.ringData[slotOff+16:])
if seq != c.readSeq {
// slot 已被新事件覆盖——逐个扫太慢(溢出场景 readSeq=0 要跳 100+ 步),
// 直接跳到 writeSeq 附近找下一个可读 slot。
// 简化:溢出后直接跳到 writeSeq - cap最旧的可读事件
if writeSeq > cap {
c.readSeq = writeSeq - cap
} else {
c.readSeq = writeSeq
}
continue
}
// 位掩码过滤
if c.typeMask != 0 && (1<<etype)&c.typeMask == 0 {
c.readSeq++
continue
}
if off > 0 && slen > 0 && uint64(off)+uint64(slen) <= uint64(len(c.ringData)) {
payload := make([]byte, slen)
copy(payload, c.ringData[off:off+slen])
var evt pubsdk.Event
if err := json.Unmarshal(payload, &evt); err == nil {
c.handler(&evt)
}
}
c.readSeq++
}
}
func (c *EvtConsumer) Stop() {
c.mu.Lock()
defer c.mu.Unlock()
if c.running {
close(c.stop)
}
}

View File

@ -0,0 +1,280 @@
package proc
import (
"fmt"
"log"
"os"
"sync"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// Host 持有**被全部子进程插件共享的一块 StageContext 段**,是共享内存数据面的
// 所有权中心§3.3/§3.4)。
//
// ❗ 为什么必须共享一块段(这是一个容易走错的关键点):
// 若每个插件各持一块段,则「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下退化成
// 副本模型——两个插件各写各的段、各自回读,最后回读者覆盖前者,
// lost update 原样复现§8.4 实测 35.8~36.8%)。
// 实验 8 的做法是 5 个 worker 进程 mmap **同一个 memfd**,本实现与之一致。
//
// 生命周期Host 由 registry 创建一次,随内核存活;每个插件 spawn 时经
// ExtraFiles 拿到同一 memfdfd 3mmap 后即看到同一份物理页。
//
// 另外持有事件环段§3.6):独立于 StageContext 的事件通知通道,
// 子进程从 eventfd 感知新事件并从 mmap 读 slot。
// fd 分配fd 3 = StageContextfd 4 = 事件环fd 5 = eventfd。
type Host struct {
memfd *os.File
data []byte
seg *Segment
shmSize int
// 事件环段(独立于 StageContext
evtfd *os.File // Unixeventfd/pipe 读端fd 5。Windows 为 nil用 evtNotifyFd 。
evtNotifyFd int // 通知句柄的平台无关标识Unix 是真 fdWindows 是伪 fd
evtRing *EvtRing // 内核侧事件环句柄
evtRingFd *os.File // Unix事件环段 memfdfd 4。Windows 为 nil命名段
evtData []byte // 事件环段 mmap 数据
// evtSubscriber 由 internal/plugin 注入coreHandler 用它接子进程的 events.subscribe 请求。
// proc 包不依赖 internal/plugin循环依赖故用接口类型存储。
evtSubscriber EvtRingSubscriber
locks *lockRegistry
stageMu sync.Mutex
coordMu sync.Mutex
coord *stageCoordinator
// sup 是内核侧唯一的子进程台账,与共享段同生命周期。
//
// 放在 Host 而不是 registry 的理由:能拿到 Host 的地方就能拿到台账,
// 而 Host 本就是「全部子进程插件共享的那一份内核侧状态」。
sup *Supervisor
}
// NewHost 创建共享段(平台层 allocShm + 布局初始化)。
//
// 段的**传递机制**按平台分开shmalloc_*.go但**布局**完全一致:
// - Linuxmemfd经 ExtraFiles 传继承 fd
// - macOS立即 unlink 的临时文件(无 memfd_create同样走 fd 继承
// - Windows命名 FileMapping无 fd 继承语义),插件按名字打开
//
// 三者共同点:全部插件看到同一份物理页,段内一律用相对偏移而非指针
// (实验 2 已验证各进程 mmap 到不同虚拟地址时偏移解引用仍正确)。
func NewHost() (*Host, error) {
memfd, data, err := allocShm(shmDefaultSize)
if err != nil {
return nil, err
}
seg, err := NewSegment(data)
if err != nil {
freeShm(memfd, data)
return nil, err
}
// 创建事件环段(独立于 StageContext
evtRingFd, evtData, efd, err := allocEvtRing()
if err != nil {
freeShm(memfd, data)
return nil, fmt.Errorf("事件环: %w", err)
}
evtRing, err := NewEvtRing(evtData)
if err != nil {
freeShm(memfd, data)
return nil, fmt.Errorf("事件环初始化: %w", err)
}
evtRing.Init()
return &Host{
sup: NewSupervisor(),
memfd: memfd,
data: data,
seg: seg,
shmSize: shmDefaultSize,
evtfd: evtfdReadFile(efd),
evtNotifyFd: efd,
evtRing: evtRing,
evtRingFd: evtRingFd,
evtData: evtData,
locks: &lockRegistry{},
}, nil
}
// shmDefaultSize 是共享 StageContext 段的大小。
//
// 取 256KBStageContext 全字段 JSON 化后典型 < 4KB工具结果中位 93B§2.5
// append-only 中间垃圾由 stage 结束时 Compact 回收256KB 给足余量。
// 全部插件共享一块,总开销恒定,不随插件数增长。
const shmDefaultSize = 256 * 1024
// Close 释放共享段StageContext + 事件环)。
// Supervisor 返回子进程台账(供 registry 查询/关停)。
func (h *Host) Supervisor() *Supervisor { return h.sup }
func (h *Host) Close() error {
// 先停全部子进程再拆段:插件还持有映射时 unmap
// 它们下一次访问共享段就是 SIGBUS。
if h.sup != nil {
h.sup.StopAll(0)
}
var firstErr error
if h.data != nil {
if err := freeShm(h.memfd, h.data); err != nil && firstErr == nil {
firstErr = err
}
h.data, h.memfd = nil, nil
}
if h.evtData != nil {
if h.evtRingFd != nil {
h.evtRingFd.Close()
h.evtRingFd = nil
}
h.evtData = nil
}
if h.evtfd != nil {
h.evtfd.Close()
h.evtfd = nil
}
evtfdClose(h.evtNotifyFd)
return firstErr
}
// beginStage 由插件 handler 进入时调用。
//
// 首个进入者:获取 stageMu独占共享段→ 把内核 StageContext 写入段。
// 后续进入者:仅递增 inflight。
func (h *Host) beginStage(sc *pubsdk.StageContext) (*stageCoordinator, error) {
h.coordMu.Lock()
first := h.coord == nil
if first {
// 独占共享段直到本次 stage 全部插件离开
h.coordMu.Unlock()
h.stageMu.Lock()
h.coordMu.Lock()
// 双检:等锁期间可能已有其他插件建好协调器(它们会先拿到 stageMu
if h.coord != nil {
first = false
h.stageMu.Unlock()
} else {
h.coord = newStageCoordinator(h.seg)
}
}
coord := h.coord
h.coordMu.Unlock()
if err := coord.enter(sc, first); err != nil {
if first {
h.coordMu.Lock()
h.coord = nil
h.coordMu.Unlock()
h.stageMu.Unlock()
}
return nil, err
}
h.locks.bind(coord.lock)
return coord, nil
}
// endStage 由插件 handler 返回时调用。
// 最后离开者:把共享段结果读回内核 StageContext → 压实 arena → 释放 stageMu。
func (h *Host) endStage(coord *stageCoordinator) error {
last, err := coord.leave()
if !last {
return err
}
h.coordMu.Lock()
h.coord = nil
h.coordMu.Unlock()
h.stageMu.Unlock()
return err
}
// ForceReleaseLock 在插件进程崩溃时释放其可能持有的 stage 锁(实验 9 自愈机制)。
func (h *Host) ForceReleaseLock(plugin string) bool {
return h.locks.forceRelease(plugin)
}
// Segment 暴露共享段(供诊断与测试)。
func (h *Host) Segment() *Segment { return h.seg }
// stageCoordinator 跟踪一次 stage 执行中参与插件的进出。
type stageCoordinator struct {
seg *Segment
lock *stageLock
mu sync.Mutex
inflight int
written bool
ctxRef *pubsdk.StageContext
}
func newStageCoordinator(seg *Segment) *stageCoordinator {
return &stageCoordinator{seg: seg, lock: newStageLock()}
}
// enter 登记一个插件进入本次 stagefirst 为真时把内核状态写入共享段。
func (c *stageCoordinator) enter(sc *pubsdk.StageContext, first bool) error {
c.mu.Lock()
defer c.mu.Unlock()
c.inflight++
if !first || c.written {
return nil
}
c.ctxRef = sc
if err := c.seg.WriteAll(sc); err != nil {
c.inflight--
return fmt.Errorf("写入共享段: %w", err)
}
c.written = true
return nil
}
// leave 登记一个插件离开;返回是否为最后一个离开者。
//
// 最后离开者负责把共享段结果读回内核 StageContext并压实 arena
// (此时无插件持锁,满足 §3.3 的压实前提)。
func (c *stageCoordinator) leave() (last bool, err error) {
c.mu.Lock()
c.inflight--
last = c.inflight == 0
sc := c.ctxRef
written := c.written
c.mu.Unlock()
if !last || !written || sc == nil {
return last, nil
}
if rErr := c.seg.ReadInto(sc); rErr != nil {
return last, fmt.Errorf("回读共享段: %w", rErr)
}
if reclaimed := c.seg.Compact(); reclaimed > 0 {
log.Printf("[proc] stage 结束arena 压实回收 %d 字节", reclaimed)
}
return last, nil
}
// ShmSize 返回共享段大小(供诊断/日志)。
func (h *Host) ShmSize() int { return h.shmSize }
// EvtRing 返回内核侧事件环句柄。
func (h *Host) EvtRing() *EvtRing { return h.evtRing }
// Evtfd 返回通知读端的 *os.FileUnixeventfd/pipe
// Windows 返回 nil——命名 Event 不是文件句柄,用 EvtNotifyFd 代替。
func (h *Host) Evtfd() *os.File { return h.evtfd }
// EvtNotifyFd 返回通知句柄的平台无关标识,供 EventRing 写通知。
//
// Unix 是真 fdWindows 是映射到命名 Event 句柄的伪 fd。
// EvtfdNotify 接受这个值并按平台分派。
func (h *Host) EvtNotifyFd() int { return h.evtNotifyFd }
// SetEvtSubscriber 注入事件环订阅接口(由 Registry 在创建 Host 后设置)。
func (h *Host) SetEvtSubscriber(sub EvtRingSubscriber) { h.evtSubscriber = sub }
// EvtData 返回事件环段 mmap 数据(子进程消费者用)。
func (h *Host) EvtData() []byte { return h.evtData }
// EvtfdReadFile 返回 eventfd 的 *os.File供子进程读取消费
func (h *Host) EvtfdReadFile() *os.File { return h.evtfd }

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)
}
}

View File

@ -0,0 +1,283 @@
package proc
import (
"context"
"encoding/json"
"fmt"
"log"
"sync"
"sync/atomic"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// Plugin 是 registry 可加载的子进程插件,与内置插件同构的启停接口。
//
// 生命周期:
//
// New() 创建(尚未 spawn
// Start(core) spawn 子进程 → 握手(传共享段 fd→ plugin.init → plugin.start
// plugin.start 期间插件反向注册工具/阶段/通道)
// Stop() plugin.stop → 宽限期 → 必要时 Kill
// Close() 强制结束registry 卸载/重载路径)
//
// **共享段不属于 Plugin**:它属于 Host被全部子进程插件共享。
// 若每插件一段,「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下会退化成
// 副本模型lost update 原样复现§8.4)。
type Plugin struct {
name string
bin string
dir string
config map[string]interface{}
host *Host
proc *Process
handler *coreHandler
// env 追加到子进程环境变量(测试用;生产由 registry 按需设置)。
env []string
// onCrash 由 registry 注入,把进程退出喂给 plugin_health.recordCrash§2.3)。
onCrash func(name string, err error)
// caps 是 manifest 声明的能力集§3.8 权限梯度)。
caps *capabilitySet
stopOnce sync.Once
// stopping 标记「本次退出是内核主动发起的」,用于压掉 onCrash。
//
// 必要性Stop() 宽限期超时与 Close() 都走 Process.Kill()
// 而 Kill 产生的 `signal: killed` 是非 nil 的 waitErr——若不区分
// 重载/禁用/卸载这些**内核自己发起**的停止会被 handleExit 当成崩溃上报,
// 触发一轮多余的自动重启(重载路径下等于把刚装好的插件又推倒一次)。
stopping atomic.Bool
}
// New 创建子进程插件(不启动进程)。
//
// host 必须是全部子进程插件共用的实例(由 registry 创建一次)。
// New 创建子进程插件(不启动进程)。
//
// host 必须是全部子进程插件共用的实例(由 registry 创建一次)。
// capabilities 来自 manifest 的 capabilities 字段;为空时不限制(存量插件向后兼容)。
func New(name, bin, dir string, config map[string]interface{}, host *Host, onCrash func(string, error), capabilities ...string) *Plugin {
return &Plugin{
name: name,
bin: bin,
dir: dir,
config: config,
host: host,
onCrash: onCrash,
caps: newCapabilitySet(capabilities),
}
}
// Name 实现 sdk.Plugin。
func (p *Plugin) Name() string { return p.name }
// PID 返回子进程号;未启动或已退出返回 0。
// 供 pluginmgr 呈现「插件实际在跑哪个进程」。
func (p *Plugin) PID() int {
if p.proc == nil {
return 0
}
if !p.Alive() {
return 0
}
return p.proc.PID()
}
// Alive 报告子进程是否仍存活。
func (p *Plugin) Alive() bool {
if p.proc == nil {
return false
}
select {
case <-p.proc.Exited():
return false
default:
return true
}
}
// Start 启动子进程并完成注册。
//
// core 是内核为该插件构建的能力面internal/sdk.PluginSDK 天然满足 CoreSDK
func (p *Plugin) Start(core CoreSDK) error {
if p.host == nil {
return fmt.Errorf("proc: %s 缺少共享段 Host", p.name)
}
p.handler = &coreHandler{
sdk: core,
name: p.name,
host: p.host,
locks: p.host.locks,
evtRing: p.host.evtSubscriber,
caps: p.caps,
}
// 反向调用闭包:注册回调时捕获,运行期经 RPC 打到插件进程。
p.handler.invokeTool = p.invokeTool
p.handler.invokeStageFn = p.invokeStage
p.handler.invokeOutput = p.invokeOutput
proc, err := Spawn(p.name, p.bin, Options{
Dir: p.dir,
// 共享段的传递机制按平台不同shmpass_*.go
// Unix 经 ExtraFiles 传继承 fd 3=StageContext, 4=事件环, 5=通知);
// Windows 无 fd 继承语义,改用命名内核对象,名字经环境变量传入。
Env: append(p.env, p.host.procEnvForShm()...),
ExtraFiles: p.host.procExtraFilesForShm(),
ShmSize: p.host.shmSize,
EvtRingSize: evtTotalSize,
Handler: p.handler.Handle,
OnExit: p.handleExit,
Supervisor: p.host.Supervisor(),
})
if err != nil {
return err
}
p.proc = proc
// plugin.init构造插件实例
if _, err := proc.Call(MethodPluginInit, PluginInitParams{
Name: p.name,
Config: p.config,
}); err != nil {
proc.Kill()
return fmt.Errorf("proc: %s plugin.init 失败: %w", p.name, err)
}
// plugin.start插件在此期间反向注册工具/阶段/通道
if _, err := proc.Call(MethodPluginStart, nil); err != nil {
proc.Kill()
return fmt.Errorf("proc: %s plugin.start 失败: %w", p.name, err)
}
return nil
}
// Stop 优雅停止(实现 sdk.Plugin
func (p *Plugin) Stop() error {
p.stopping.Store(true)
var err error
p.stopOnce.Do(func() {
if p.proc != nil {
err = p.proc.Stop()
}
})
return err
}
// Close 强制结束子进程。
//
// **这里是真 kill + wait**——对比 cabi 路径的 Close 只做 dlclose
// 而 dlclose 对 Go c-shared 是 no-op§1.1,热重载静默失效的根因)。
func (p *Plugin) Close() error {
p.stopping.Store(true)
var err error
p.stopOnce.Do(func() {
if p.proc != nil {
err = p.proc.Kill()
}
})
return err
}
// handleExit 在子进程退出时把信号喂给 plugin_health§2.3 逻辑复用),
// 并释放该插件可能持有的 stage 锁。
//
// 后者是"锁仲裁回内核"的自愈价值:持锁者死亡不会导致全局死锁,
// 无需 robust pthread_mutex实验 9
func (p *Plugin) handleExit(name string, err error) {
if p.host != nil && p.host.ForceReleaseLock(name) {
log.Printf("[proc] %s 退出,内核已释放其持有的 stage 锁", name)
}
// 内核主动停止Stop/Close含宽限期超时后的 Kill不算崩溃
// 否则重载/禁用/卸载都会误触发自动重启。
if p.stopping.Load() {
return
}
if err != nil && p.onCrash != nil {
p.onCrash(name, err)
}
}
// ---- 内核 → 插件的反向调用 ----
func (p *Plugin) invokeTool(name string, args map[string]interface{}) (interface{}, error) {
if p.proc == nil {
return nil, ErrProcessExited
}
raw, err := p.proc.Call(MethodToolInvoke, ToolInvokeParams{Name: name, Args: args})
if err != nil {
return nil, err
}
var res ToolInvokeResult
if err := json.Unmarshal(raw, &res); err != nil {
return nil, fmt.Errorf("proc: %s 工具 %s 应答解析失败: %w", p.name, name, err)
}
return res.Result, nil
}
func (p *Plugin) invokeStage(ctx context.Context, stage string, seq uint64) error {
if p.proc == nil {
return ErrProcessExited
}
raw, err := p.proc.CallContext(ctx, MethodStageInvoke, StageInvokeParams{
Stage: stage,
Seq: seq,
})
if err != nil {
return err
}
var res StageInvokeResult
if len(raw) > 0 {
if err := json.Unmarshal(raw, &res); err != nil {
return fmt.Errorf("proc: %s stage %s 应答解析失败: %w", p.name, stage, err)
}
}
if res.DirtyFields > 0 {
log.Printf("[proc] %s stage %s 改写了 %d 个字段", p.name, stage, res.DirtyFields)
}
return nil
}
// invokeOutput 经插件输出通道发送,**同步等待真实结果**。
//
// 这是 §9.4 的根治C ABI 下 cgo 不可嵌套,只能异步 fire-and-forget
// 导致 output_send 永远返回 {status:queued} + err=nil模型永远以为发送成功
// (现网 7 天内 2 次消息实际发不出)。进程模型下 RPC 天然可等应答。
func (p *Plugin) invokeOutput(channel string, args map[string]interface{}) (interface{}, error) {
if p.proc == nil {
return nil, ErrProcessExited
}
raw, err := p.proc.Call(MethodOutputInvoke, OutputInvokeParams{
Channel: channel,
Args: args,
})
if err != nil {
return nil, err // 真实失败上报,模型可感知并重试
}
if len(raw) == 0 {
return map[string]interface{}{"status": "sent"}, nil
}
var res map[string]interface{}
if err := json.Unmarshal(raw, &res); err != nil {
return map[string]interface{}{"status": "sent"}, nil
}
if _, ok := res["status"]; !ok {
res["status"] = "sent"
}
return res, nil
}
// 编译期确认 Plugin 具备 registry 需要的启停形状。
var _ interface {
Name() string
Stop() error
Close() error
} = (*Plugin)(nil)
// 引用一下公开 SDK确保本文件的类型假设与它同版本。
var _ = pubsdk.StageScopeGlobal

View File

@ -0,0 +1,393 @@
package proc
import (
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// 端到端验证:内核 RunStage 并发扇出 → 真实子进程插件经共享内存读改写 → 结果回读。
//
// 这是**整个迁移最关键的一环闭环验证**§4.4 风险 3.4
// 机制在 shm_test.go 已被单元验证,这里验证它在真进程 + 真 RPC 下同样成立。
// fakeCoreSDK 是最简 CoreSDK 实现,记录注册行为。
type fakeCoreSDK struct {
mu sync.Mutex
tools map[string]pubsdk.ToolHandler
stages map[pubsdk.Stage][]pubsdk.StageHandler
outputs map[string]pubsdk.ToolHandler
settings map[string]interface{}
autoStart bool
}
func newFakeCore() *fakeCoreSDK {
return &fakeCoreSDK{
tools: map[string]pubsdk.ToolHandler{},
stages: map[pubsdk.Stage][]pubsdk.StageHandler{},
outputs: map[string]pubsdk.ToolHandler{},
settings: map[string]interface{}{},
}
}
func (f *fakeCoreSDK) PluginName() string { return "fake" }
func (f *fakeCoreSDK) Settings() pubsdk.SettingsAPI { return nil }
func (f *fakeCoreSDK) Memory() pubsdk.MemoryAPI { return nil }
func (f *fakeCoreSDK) TextMemory() pubsdk.TextMemoryAPI { return nil }
func (f *fakeCoreSDK) DocMemory() pubsdk.DocMemoryAPI { return nil }
func (f *fakeCoreSDK) Knowledge() pubsdk.KnowledgeAPI { return nil }
func (f *fakeCoreSDK) LLM() pubsdk.LLMAPI { return nil }
func (f *fakeCoreSDK) Social() pubsdk.SocialAPI { return nil }
func (f *fakeCoreSDK) PluginMgr() pubsdk.PluginMgrAPI { return nil }
func (f *fakeCoreSDK) RegisterPluginAPI(name string) error { return nil }
func (f *fakeCoreSDK) InjectText(s, c, t string) {}
func (f *fakeCoreSDK) InjectInterruptText(s, c, t string) {}
func (f *fakeCoreSDK) InjectTextNoMemory(s, c, t string) {}
func (f *fakeCoreSDK) InjectInputSync(s, c, t string) string { return "" }
func (f *fakeCoreSDK) SetAutoRestart(enabled bool) { f.autoStart = enabled }
func (f *fakeCoreSDK) RegisterTool(name string, def pubsdk.ToolDef, h pubsdk.ToolHandler) error {
f.mu.Lock()
defer f.mu.Unlock()
f.tools[name] = h
return nil
}
func (f *fakeCoreSDK) RegisterStage(stage pubsdk.Stage, h pubsdk.StageHandler, scope ...pubsdk.StageScope) {
f.mu.Lock()
defer f.mu.Unlock()
f.stages[stage] = append(f.stages[stage], h)
}
func (f *fakeCoreSDK) RegisterOutputChannel(name string, caps int, desc string, def pubsdk.ChannelDef, h pubsdk.ToolHandler) error {
f.mu.Lock()
defer f.mu.Unlock()
f.outputs[name] = h
return nil
}
func (f *fakeCoreSDK) RegisterInputChannel(name string, def pubsdk.ChannelDef) error { return nil }
func (f *fakeCoreSDK) stageHandlers(stage pubsdk.Stage) []pubsdk.StageHandler {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]pubsdk.StageHandler, len(f.stages[stage]))
copy(out, f.stages[stage])
return out
}
// runStageLikeKernel 复刻 internal/agent/core.StageHost.RunStage 的并发扇出语义
// stages.go:124 的 go func + wg.Wait验证外部插件在同样的并发模型下正确工作。
func runStageLikeKernel(handlers []pubsdk.StageHandler, sc *pubsdk.StageContext) []error {
var wg sync.WaitGroup
errCh := make(chan error, len(handlers))
for _, h := range handlers {
wg.Add(1)
go func(fn pubsdk.StageHandler) {
defer wg.Done()
if err := fn(sc); err != nil {
errCh <- err
}
}(h)
}
wg.Wait()
close(errCh)
var errs []error
for err := range errCh {
errs = append(errs, err)
}
return errs
}
// 单插件 stage 读改写:验证共享段 + RPC + 锁的完整链路。
func TestPlugin_StageReadModifyWriteOverSharedMemory(t *testing.T) {
bin := buildTestPlugin(t, "stageplugin.go")
core := newFakeCore()
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
p := New("sanitizer", bin, t.TempDir(), nil, host, nil)
if err := p.Start(core); err != nil {
t.Fatalf("Start: %v", err)
}
defer p.Close()
handlers := core.stageHandlers(pubsdk.StageAfterToolcall)
if len(handlers) != 1 {
t.Fatalf("插件应注册 1 个 after_toolcall handler实际 %d", len(handlers))
}
dirty := "结果:\x1b[31m脏数据\x1b[0m"
clean := "结果:脏数据"
sc := &pubsdk.StageContext{
Phase: pubsdk.StageAfterToolcall,
ToolResults: []pubsdk.ToolResult{{CallID: "c1", Name: "x_tool", Result: dirty}},
}
if errs := runStageLikeKernel(handlers, sc); len(errs) > 0 {
t.Fatalf("stage 执行失败: %v", errs)
}
got, _ := sc.ToolResults[0].Result.(string)
if got != clean {
t.Fatalf("插件的清洗结果未回到内核 StageContext期望 %q实际 %q", clean, got)
}
}
// **核心断言**:改写型插件 + 只读插件并发时,清洗结果不被覆盖。
// 复刻现网 sanitizer + weather 场景§8.6 实测 C ABI 下 1.6~4.3% 被覆盖)。
func TestPlugin_ConcurrentWriterAndReaderNoLostUpdate(t *testing.T) {
bin := buildTestPlugin(t, "stageplugin.go")
// ❗ 两个插件进程**共享同一个 Host**(同一 memfd——这是消除 lost update 的前提。
// 若各持一段,「内核 ctx → 段 → 插件改 → 回读 ctx」会退化成副本模型
// 最后回读者覆盖前者§8.4 的 35.8~36.8% 丢失原样复现。
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
writerCore := newFakeCore()
writer := New("sanitizer", bin, t.TempDir(), nil, host, nil)
if err := writer.Start(writerCore); err != nil {
t.Fatalf("writer Start: %v", err)
}
defer writer.Close()
readerBin := buildTestPlugin(t, "readonlyplugin.go")
readerCore := newFakeCore()
reader := New("weather", readerBin, t.TempDir(), nil, host, nil)
if err := reader.Start(readerCore); err != nil {
t.Fatalf("reader Start: %v", err)
}
defer reader.Close()
handlers := append(
writerCore.stageHandlers(pubsdk.StageAfterToolcall),
readerCore.stageHandlers(pubsdk.StageAfterToolcall)...,
)
if len(handlers) != 2 {
t.Fatalf("应有 2 个 handler实际 %d", len(handlers))
}
dirty := "天气:晴 \x1b[31m28°C\x1b[0m"
clean := "天气:晴 28°C"
sc := &pubsdk.StageContext{
Phase: pubsdk.StageAfterToolcall,
ToolResults: []pubsdk.ToolResult{{CallID: "c1", Name: "weather_query", Result: dirty}},
}
if errs := runStageLikeKernel(handlers, sc); len(errs) > 0 {
t.Fatalf("stage 执行失败: %v", errs)
}
got, _ := sc.ToolResults[0].Result.(string)
if got != clean {
t.Fatalf("只读插件覆盖了改写插件的清洗结果:期望 %q实际 %q", clean, got)
}
}
// 插件注册的工具可被内核调用,并把结果带回。
func TestPlugin_RegisteredToolInvokable(t *testing.T) {
bin := buildTestPlugin(t, "stageplugin.go")
core := newFakeCore()
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
p := New("demo", bin, t.TempDir(), nil, host, nil)
if err := p.Start(core); err != nil {
t.Fatalf("Start: %v", err)
}
defer p.Close()
core.mu.Lock()
h, ok := core.tools["demo_upper"]
core.mu.Unlock()
if !ok {
t.Fatal("插件应注册 demo_upper 工具")
}
res, err := h(map[string]interface{}{"text": "abc"})
if err != nil {
t.Fatalf("调用工具: %v", err)
}
if res != "ABC" {
t.Fatalf("工具结果应为 ABC实际 %v", res)
}
}
// 输出通道**同步等真实结果**失败必须上报§9.4 根治)。
func TestPlugin_OutputChannelReportsRealFailure(t *testing.T) {
bin := buildTestPlugin(t, "stageplugin.go")
core := newFakeCore()
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
p := New("demo", bin, t.TempDir(), nil, host, nil)
if err := p.Start(core); err != nil {
t.Fatalf("Start: %v", err)
}
defer p.Close()
core.mu.Lock()
h, ok := core.outputs["demo_ch"]
core.mu.Unlock()
if !ok {
t.Fatal("插件应注册 demo_ch 输出通道")
}
// 成功路径
res, err := h(map[string]interface{}{"payload": "hi", "type": "text"})
if err != nil {
t.Fatalf("发送应成功: %v", err)
}
m, _ := res.(map[string]interface{})
if m["status"] != "sent" {
t.Errorf("成功应返回 status=sent实际 %v", m)
}
// 失败路径:插件返回错误 → 调用方必须收到 error而非假成功
_, err = h(map[string]interface{}{"payload": "fail", "type": "text"})
if err == nil {
t.Fatal("发送失败时必须上报 errorC ABI 路径此处永远假成功)")
}
if !strings.Contains(err.Error(), "缺少 user_id") {
t.Errorf("应透传插件的失败原因,实际: %v", err)
}
}
// 插件在 plugin.start 期间反向调用内核settings/autoRestart 等)。
func TestPlugin_ReverseCallsDuringStart(t *testing.T) {
bin := buildTestPlugin(t, "stageplugin.go")
core := newFakeCore()
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
p := New("demo", bin, t.TempDir(), nil, host, nil)
if err := p.Start(core); err != nil {
t.Fatalf("Start: %v", err)
}
defer p.Close()
if !core.autoStart {
t.Error("插件调用 lifecycle.autoRestart 后内核状态应更新")
}
}
// 权限梯度显式化§3.8CoreSDK 不提供内核内部机制,
// 插件请求这些能力时必须被拒绝而非静默忽略。
func TestCoreHandler_RejectsUnknownAndUnimplementedMethods(t *testing.T) {
h := &coreHandler{sdk: newFakeCore(), name: "x", locks: &lockRegistry{}}
// 未知 method
if _, err := h.Handle("supervisor.restart", nil); err == nil {
t.Error("内核内部机制不应可达(应报未知 method")
}
// 事件订阅:今日 C ABI 是空实现(静默成功),这里必须明确报未实现
if _, err := h.Handle(MethodEventsSubscribe, json.RawMessage(`{}`)); err == nil {
t.Error("事件订阅未落地时应明确报错,而非静默成功后收不到事件")
}
// 多模态注入同理
if _, err := h.Handle(MethodIOSetToolBlocks, json.RawMessage(`{}`)); err == nil {
t.Error("多模态注入未落地时应明确报错")
}
}
// stage 锁在无进行中 stage 时申请应被拒绝(防止插件在 stage 外乱加锁)。
func TestCoreHandler_StageLockOutsideStageRejected(t *testing.T) {
h := &coreHandler{sdk: newFakeCore(), name: "x", locks: &lockRegistry{}}
if _, err := h.Handle(MethodStageLock, nil); err == nil {
t.Error("stage 外加锁应被拒绝")
}
if !strings.Contains(fmt.Sprint(mustErr(h.Handle(MethodStageUnlock, nil))), "无进行中的 stage") {
t.Error("stage 外解锁的错误信息应说明原因")
}
}
func mustErr(_ interface{}, err error) error { return err }
// **跨进程 lost update 终极验证**5 个独立插件进程并发读-改-写同一个
// FinalText全部标记必须保留。
//
// 这是实验 85 进程 × 300 轮零丢失)在真实 RPC + 真实 RunStage 并发扇出
// 下的复刻。对照今日 C ABI 副本模型实测 35.8~36.8% 丢失§8.4)。
func TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate(t *testing.T) {
bin := buildTestPlugin(t, "appendplugin.go")
// 关键:全部插件共享同一个 Host同一 memfd
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
tags := []string{"A", "B", "C", "D", "E"}
var handlers []pubsdk.StageHandler
for _, tag := range tags {
core := newFakeCore()
p := New("append-"+tag, bin, t.TempDir(), nil, host, nil)
p.env = []string{"PLUGIN_TAG=" + tag}
if err := p.Start(core); err != nil {
t.Fatalf("插件 %s Start: %v", tag, err)
}
defer p.Close()
handlers = append(handlers, core.stageHandlers(pubsdk.StageAfterToolcall)...)
}
if len(handlers) != len(tags) {
t.Fatalf("应有 %d 个 handler实际 %d", len(tags), len(handlers))
}
sc := &pubsdk.StageContext{
Phase: pubsdk.StageAfterToolcall,
FinalText: "",
}
if errs := runStageLikeKernel(handlers, sc); len(errs) > 0 {
t.Fatalf("并发 stage 执行失败: %v", errs)
}
// 断言:各标记出现次数之和 == 最终长度 == 插件数 ⇒ 无丢失、无撕裂
total := 0
counts := map[string]int{}
for _, tag := range tags {
c := strings.Count(sc.FinalText, tag)
counts[tag] = c
total += c
}
if total != len(sc.FinalText) {
t.Fatalf("出现撕裂:各标记计数之和 %d != 最终长度 %dfinal=%q counts=%v",
total, len(sc.FinalText), sc.FinalText, counts)
}
if total != len(tags) {
t.Fatalf("出现 lost update期望 %d 个插件的写入全部保留,实际 %dfinal=%q counts=%v",
len(tags), total, sc.FinalText, counts)
}
for tag, c := range counts {
if c != 1 {
t.Errorf("插件 %s 的写入丢失:期望 1 次,实际 %d 次", tag, c)
}
}
}

View File

@ -0,0 +1,30 @@
//go:build linux
package proc
import (
"os/exec"
"syscall"
)
// applyProcAttr 让子进程在父进程homed死亡时收到 SIGKILL。
//
// 这是**最后一道兜底**,不是主路径:正常关停走 Supervisor.StopAll。
// 它兜的是内核自身异常终止的场景——homed 被 SIGKILL、段错误、OOM——
// 此时没有任何 Go 代码有机会运行Supervisor 也来不及 StopAll
// 子进程会被 init 收养成孤儿:
// - 继续持有已被 unmap 的共享段映射,下次访问即 SIGBUS
// - 与新启动的 homed 抢同一份外部资源qq 的 WS 会话、browser 的
// chromium profile 锁),表现为"重启后插件时好时坏"。
//
// Pdeathsig 由内核在父进程退出时投递,不依赖任何用户态代码,
// 因此在 homed 被 SIGKILL 的情况下依然生效。
//
// 仅 Linux 有此机制。macOS/Windows 无等价物回退为空实现procattr_other.go
// 那两个平台上孤儿风险依旧存在,靠 StopAll 覆盖正常关停路径。
func applyProcAttr(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Pdeathsig = syscall.SIGKILL
}

View File

@ -0,0 +1,15 @@
//go:build !linux
package proc
import "os/exec"
// applyProcAttr 在非 Linux 平台是空实现。
//
// macOS 没有 Pdeathsigkqueue 的 NOTE_EXIT 要求父进程存活才能监听,
// 恰好在父进程被 SIGKILL 时失效Windows 的 Job Object 可做到类似效果,
// 但需要额外的句柄管理,且 Windows 侧尚未真机验证§12.5),不在此引入。
//
// 后果:这两个平台上 homed 被强杀时子进程会成为孤儿。
// 正常关停路径Supervisor.StopAll不受影响。
func applyProcAttr(cmd *exec.Cmd) {}

View File

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

View File

@ -0,0 +1,481 @@
package proc
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// Process 的测试用真实子进程go build 出的小二进制),而非 mock
// 崩溃隔离、EOF 感知、Kill 回收这些性质只有真进程才能验证——
// 它们恰是迁移相对 C ABI 的核心收益§9.5)。
// buildTestPlugin 编译 testdata 下的假插件,返回二进制路径。
func buildTestPlugin(t *testing.T, srcName string) string {
t.Helper()
if _, err := exec.LookPath("go"); err != nil {
t.Skip("环境无 go 工具链,跳过子进程测试")
}
src := filepath.Join("testdata", srcName)
if _, err := os.Stat(src); err != nil {
t.Fatalf("测试插件源码缺失 %s: %v", src, err)
}
bin := filepath.Join(t.TempDir(), strings.TrimSuffix(srcName, ".go"))
cmd := exec.Command("go", "build", "-o", bin, src)
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("编译测试插件 %s 失败: %v\n%s", srcName, err, out)
}
return bin
}
// noopHandler 是最简的内核侧 handler测试中不需要真实 core.* 能力)。
func noopHandler(method string, params json.RawMessage) (interface{}, error) {
return nil, fmt.Errorf("测试环境未实现 %s", method)
}
func TestProcess_SpawnHandshakeAndToolInvoke(t *testing.T) {
bin := buildTestPlugin(t, "echoplugin.go")
p, err := Spawn("echo", bin, Options{Handler: noopHandler})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
defer p.Kill()
if p.PID() == 0 {
t.Error("PID 应非零")
}
raw, err := p.Call(MethodToolInvoke, ToolInvokeParams{
Name: "echo_tool",
Args: map[string]interface{}{"text": "你好"},
})
if err != nil {
t.Fatalf("tool.invoke: %v", err)
}
var res ToolInvokeResult
if err := json.Unmarshal(raw, &res); err != nil {
t.Fatalf("解析应答: %v", err)
}
if res.Result != "你好" {
t.Fatalf("工具应回显 '你好',实际 %v", res.Result)
}
}
// 插件返回错误时调用方必须收到 error —— 对比 C ABI 路径的 output_send
// 永远返回成功§9.4,现网 2 次消息发不出而模型以为成功)。
func TestProcess_PluginErrorIsReported(t *testing.T) {
bin := buildTestPlugin(t, "echoplugin.go")
p, err := Spawn("echo", bin, Options{Handler: noopHandler})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
defer p.Kill()
_, err = p.Call(MethodToolInvoke, ToolInvokeParams{Name: "fail_tool"})
if err == nil {
t.Fatal("插件返回错误时调用方应收到 error")
}
if !strings.Contains(err.Error(), "故意失败") {
t.Errorf("错误信息应透传插件的原因,实际: %v", err)
}
}
// 插件反向调用内核51 个 core.* method 的机制验证)。
func TestProcess_PluginCallsBackIntoKernel(t *testing.T) {
bin := buildTestPlugin(t, "callbackplugin.go")
var (
mu sync.Mutex
gotCall []string
)
handler := func(method string, params json.RawMessage) (interface{}, error) {
mu.Lock()
gotCall = append(gotCall, method)
mu.Unlock()
switch method {
case MethodSettingsGet:
return map[string]interface{}{"value": "配置值"}, nil
case MethodToolRegister:
return nil, nil
}
return nil, fmt.Errorf("未实现 %s", method)
}
p, err := Spawn("cb", bin, Options{Handler: handler})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
defer p.Kill()
// plugin.start 期间插件会回调 tool.register + settings.get
if _, err := p.Call(MethodPluginStart, nil); err != nil {
t.Fatalf("plugin.start: %v", err)
}
mu.Lock()
defer mu.Unlock()
if len(gotCall) < 2 {
t.Fatalf("内核应收到插件的反向调用,实际 %v", gotCall)
}
hasRegister, hasSettings := false, false
for _, m := range gotCall {
if m == MethodToolRegister {
hasRegister = true
}
if m == MethodSettingsGet {
hasSettings = true
}
}
if !hasRegister || !hasSettings {
t.Errorf("应收到 tool.register 与 settings.get实际 %v", gotCall)
}
}
// 崩溃隔离:插件 panic 只让子进程退出,内核存活并收到 onExit§9.5 表格第 2 行)。
// C ABI 路径下 panic 跨 C 栈recover 兜不住会带崩整个 homed§1.4)。
func TestProcess_CrashIsolationAndExitDetection(t *testing.T) {
bin := buildTestPlugin(t, "crashplugin.go")
exitCh := make(chan error, 1)
p, err := Spawn("crash", bin, Options{
Handler: noopHandler,
OnExit: func(name string, err error) { exitCh <- err },
})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
// 触发插件 panic
_, callErr := p.Call(MethodToolInvoke, ToolInvokeParams{Name: "boom"})
if callErr == nil {
t.Error("插件崩溃时在途调用应返回错误,而非挂死")
}
select {
case exitErr := <-exitCh:
if exitErr == nil {
t.Error("panic 退出应报告非 nil 错误(供 recordCrash 使用)")
}
case <-time.After(5 * time.Second):
t.Fatal("未在 5s 内检测到进程退出EOF 感知失效)")
}
select {
case <-p.Exited():
case <-time.After(time.Second):
t.Error("Exited() 通道应已关闭")
}
// 进程已退出后继续调用应立即失败,不能挂死
if _, err := p.Call(MethodToolInvoke, ToolInvokeParams{Name: "echo_tool"}); !errors.Is(err, ErrProcessExited) {
t.Errorf("退出后调用应返回 ErrProcessExited实际 %v", err)
}
}
// 优雅停止plugin.stop 后进程自行退出。
func TestProcess_GracefulStop(t *testing.T) {
bin := buildTestPlugin(t, "echoplugin.go")
p, err := Spawn("echo", bin, Options{Handler: noopHandler})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
if err := p.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
select {
case <-p.Exited():
case <-time.After(3 * time.Second):
t.Fatal("Stop 后进程应退出")
}
if err := p.ExitError(); err != nil {
t.Errorf("优雅停止应无错误退出,实际 %v", err)
}
}
// **真正的取消**:卡死的插件可被 Kill 回收§9.3 对照 cgo 超时线程永久泄漏)。
func TestProcess_KillHungPlugin(t *testing.T) {
bin := buildTestPlugin(t, "hangplugin.go")
p, err := Spawn("hang", bin, Options{Handler: noopHandler})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
// 调用会卡住,用 context 超时返回(调用方不被拖死)
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
_, err = p.CallContext(ctx, MethodToolInvoke, ToolInvokeParams{Name: "hang_tool"})
if err == nil {
t.Fatal("卡死的调用应因 ctx 超时返回")
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("应为 DeadlineExceeded实际 %v", err)
}
// pending 条目必须已清理(不泄漏)
p.mu.Lock()
pendingCount := len(p.pending)
p.mu.Unlock()
if pendingCount != 0 {
t.Errorf("超时后 pending 表应清空,实际残留 %d 条", pendingCount)
}
// Kill 真正回收资源
if err := p.Kill(); err != nil {
t.Fatalf("Kill: %v", err)
}
select {
case <-p.Exited():
case <-time.After(3 * time.Second):
t.Fatal("Kill 后进程应退出")
}
}
// 通知ID=0不等应答——事件投递路径必须 post-and-forget§2.4 约束 B
func TestProcess_NotifyDoesNotWait(t *testing.T) {
bin := buildTestPlugin(t, "echoplugin.go")
p, err := Spawn("echo", bin, Options{Handler: noopHandler})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
defer p.Kill()
start := time.Now()
for i := 0; i < 100; i++ {
if err := p.Notify("event.deliver", map[string]interface{}{"seq": i}); err != nil {
t.Fatalf("Notify: %v", err)
}
}
elapsed := time.Since(start)
// 100 条通知若每条都等应答,至少要 100 个往返post-and-forget 应远快于此
if elapsed > time.Second {
t.Errorf("100 条通知耗时 %v疑似在等应答应 post-and-forget", elapsed)
}
// 通知不占 pending 表
p.mu.Lock()
pendingCount := len(p.pending)
p.mu.Unlock()
if pendingCount != 0 {
t.Errorf("通知不应占用 pending 表,实际 %d 条", pendingCount)
}
}
// 并发调用pending 表按 ID 正确路由,应答不串。
func TestProcess_ConcurrentCallsRouteCorrectly(t *testing.T) {
bin := buildTestPlugin(t, "echoplugin.go")
p, err := Spawn("echo", bin, Options{Handler: noopHandler})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
defer p.Kill()
const n = 50
var wg sync.WaitGroup
errs := make(chan error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
want := fmt.Sprintf("msg-%d", i)
raw, err := p.Call(MethodToolInvoke, ToolInvokeParams{
Name: "echo_tool",
Args: map[string]interface{}{"text": want},
})
if err != nil {
errs <- err
return
}
var res ToolInvokeResult
if err := json.Unmarshal(raw, &res); err != nil {
errs <- err
return
}
if res.Result != want {
errs <- fmt.Errorf("应答串了:期望 %q实际 %v", want, res.Result)
}
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
t.Errorf("并发调用失败: %v", err)
}
}
// 协议版本不匹配必须显式拒绝,不能半兼容运行。
func TestProcess_ProtocolMismatchRejected(t *testing.T) {
bin := buildTestPlugin(t, "badprotoplugin.go")
_, err := Spawn("badproto", bin, Options{Handler: noopHandler})
if err == nil {
t.Fatal("协议版本不匹配应拒绝建链")
}
if !strings.Contains(err.Error(), "协议版本不匹配") {
t.Errorf("错误应说明版本不匹配,实际: %v", err)
}
}
func TestProcess_SpawnRequiresHandler(t *testing.T) {
if _, err := Spawn("x", "/bin/true", Options{}); err == nil {
t.Fatal("缺少 Handler 应报错(插件无法回调内核)")
}
}
// 插件死亡但孙子进程仍持有 stdout 写端时,内核必须仍能感知退出。
//
// 这是「EOF 不等于进程死亡」的回归测试。旧实现只在 readLoop 读到 EOF 后
// 才 markExited而 exec.Command 起的孙子进程默认继承插件的 stdout
// 插件本体退出后写端仍被孙子持有EOF 永不到来,于是
// - 在途调用挂到自己的超时;
// - OnExit 不触发 → 崩溃计数、工具摘除、自动重启全都不发生;
// - 进程表里插件已是僵尸,注册表里却一切正常。
// 生产上 browser 拉 chromium、editdoc 拉 python 正是这个形状。
// 现在由专职 waitLoop 直接 wait4(2) 判定,不再依赖 fd 生命周期。
func TestProcess_ExitDetectedDespiteInheritedStdout(t *testing.T) {
if _, err := exec.LookPath("sleep"); err != nil {
t.Skip("环境无 sleep跳过")
}
bin := buildTestPlugin(t, "forkplugin.go")
exitCh := make(chan error, 1)
p, err := Spawn("fork", bin, Options{
Handler: noopHandler,
OnExit: func(name string, err error) { exitCh <- err },
})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
defer p.Kill()
// 让插件本体退出(孙子 sleep 300 仍活着,继续持有 stdout 写端)
if _, callErr := p.Call(MethodToolInvoke, ToolInvokeParams{Name: "die"}); callErr == nil {
t.Error("插件退出时在途调用应返回错误")
}
select {
case exitErr := <-exitCh:
if exitErr == nil {
t.Error("非零退出码应报告为错误(供崩溃计数使用)")
}
case <-time.After(5 * time.Second):
t.Fatal("孙子进程持有 stdout 时未能感知插件退出——退化回只靠 EOF 判定")
}
if _, err := p.Call(MethodToolInvoke, ToolInvokeParams{Name: "x"}); !errors.Is(err, ErrProcessExited) {
t.Errorf("退出后调用应返回 ErrProcessExited实际 %v", err)
}
}
// Supervisor 台账:握手成功即在册,进程退出即注销。
func TestSupervisor_TrackAndUntrack(t *testing.T) {
bin := buildTestPlugin(t, "echoplugin.go")
sup := NewSupervisor()
p, err := Spawn("echo", bin, Options{Handler: noopHandler, Supervisor: sup})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
if sup.Count() != 1 {
t.Fatalf("握手成功后应在册,实际 %d", sup.Count())
}
got, ok := sup.Get("echo")
if !ok || got.PID() != p.PID() {
t.Errorf("台账里的进程应是刚 spawn 的那个")
}
list := sup.List()
if len(list) != 1 || !list[0].Alive || list[0].PID != p.PID() {
t.Errorf("List 应报告存活与 PID实际 %+v", list)
}
if err := p.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
// 退出回调在 markExited 里注销,等它落地
deadline := time.Now().Add(3 * time.Second)
for sup.Count() != 0 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if sup.Count() != 0 {
t.Errorf("进程退出后应注销,实际仍有 %d 个在册", sup.Count())
}
}
// StopAll 必须停掉全部在册子进程——内核关停时不留孤儿。
func TestSupervisor_StopAllLeavesNoSurvivor(t *testing.T) {
bin := buildTestPlugin(t, "echoplugin.go")
sup := NewSupervisor()
var procs []*Process
for i := 0; i < 3; i++ {
p, err := Spawn(fmt.Sprintf("echo%d", i), bin, Options{Handler: noopHandler, Supervisor: sup})
if err != nil {
t.Fatalf("Spawn %d: %v", i, err)
}
procs = append(procs, p)
}
if sup.Count() != 3 {
t.Fatalf("应有 3 个在册,实际 %d", sup.Count())
}
sup.StopAll(5 * time.Second)
for _, p := range procs {
select {
case <-p.Exited():
case <-time.After(2 * time.Second):
t.Errorf("%s 未被 StopAll 停掉(会成为孤儿进程)", p.Name())
}
}
}
// 卡死插件(不响应 plugin.stop必须在 StopAll 的预算内被强杀。
func TestSupervisor_StopAllKillsUnresponsive(t *testing.T) {
bin := buildTestPlugin(t, "hangplugin.go")
sup := NewSupervisor()
p, err := Spawn("hang", bin, Options{Handler: noopHandler, Supervisor: sup})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
// 预算给足以覆盖 stopGracePeriod之后剩下的一律 Kill
sup.StopAll(500 * time.Millisecond)
select {
case <-p.Exited():
case <-time.After(10 * time.Second):
t.Error("不响应 plugin.stop 的插件应被强制结束,否则 homed 关停会被它拖住")
}
}
// 关停后完成握手的进程不得留存:立即被结束,不能活过内核。
func TestSupervisor_TrackAfterCloseKillsProcess(t *testing.T) {
bin := buildTestPlugin(t, "echoplugin.go")
sup := NewSupervisor()
sup.StopAll(time.Second) // 置 closed
p, err := Spawn("late", bin, Options{Handler: noopHandler, Supervisor: sup})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
if sup.Count() != 0 {
t.Errorf("关停后不应再纳管新进程,实际在册 %d", sup.Count())
}
select {
case <-p.Exited():
case <-time.After(3 * time.Second):
t.Error("关停后冒出的进程应被立即结束")
}
}

View File

@ -0,0 +1,215 @@
package proc
import "encoding/json"
// RPC 协议定义控制面§3.2 method id 平移为 method 名)。
//
// 帧格式:**换行分隔的 JSON**NDJSON双向复用同一对 stdio 管道。
// 内核 → 插件 stdin :请求 / 响应
// 插件 → 内核 stdout请求 / 响应
//
// 为什么不用 length-prefixed 二进制帧:工具调用结果中位数仅 93B§2.5
// JSON 序列化 3-8 µs 对比 LLM 单轮 2-8 秒占 0.0001%,可读性与可调试性更值。
// 大 payload多媒体二进制走共享内存 arena不进 RPC 帧§3.3 实验 1018-22x
// 协议版本:与共享段版本独立演进。
// 插件握手时上报,内核校验——不匹配显式拒绝,避免半兼容导致的诡异行为。
const ProtocolVersion = 1
// Direction 无需显式字段:靠 Method 是否为空区分请求与响应
// (与 clawhubadapter/sidecar 的成熟做法一致)。
// Request 是一次 RPC 调用。
//
// ID 语义:
// - ID > 0 :需要响应,调用方在 pending 表等待
// - ID == 0 通知fire-and-forget被调方不得回响应
//
// 通知用于事件投递等不关心结果的路径§2.4 约束 B内核发通知绝不等待消费者
type Request struct {
ID uint64 `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
// Response 是对 Request 的应答。Error 非空表示失败。
type Response struct {
ID uint64 `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// ---- kernel → plugin内核调用插件对应今日 7 个 //export----
const (
// MethodPluginInit 传插件名与配置,插件构造实例但不启动。
MethodPluginInit = "plugin.init"
// MethodPluginStart 插件注册工具/阶段/通道(其间会反向发起大量 core.* 调用)。
MethodPluginStart = "plugin.start"
// MethodPluginStop 优雅停止:插件侧先跑 RunStopHandlers 再 Stop()。
MethodPluginStop = "plugin.stop"
// MethodToolInvoke 执行插件工具。
MethodToolInvoke = "tool.invoke"
// MethodStageInvoke 执行阶段处理器。数据经共享段传递,参数只带阶段名与段世代号。
MethodStageInvoke = "stage.invoke"
// MethodOutputInvoke 经插件输出通道发送。
MethodOutputInvoke = "output.invoke"
// MethodHandshake 建链首帧交换协议版本、SDK 版本、共享段规格。
MethodHandshake = "handshake"
)
// ---- plugin → kernel51 个 method id 平移§3.2----
//
// 编号本身扔掉:不再维护"下一个可用 id 是 52",加能力不用改两边常量表,
// 也不再出现 47 夹在 7 和 8 之间的历史痕迹。
const (
// 注册面(原 case 1/2/3/4/46
MethodToolRegister = "tool.register" // 1 CORE_REGISTER_TOOL
MethodStageRegister = "stage.register" // 2 CORE_REGISTER_STAGE
MethodOutputRegister = "output.register" // 3 CORE_REGISTER_OUTPUT_CH
MethodAPIRegister = "api.register" // 4 CORE_REGISTER_PLUGIN_API
MethodInputRegister = "input.register" // 46 CORE_REGISTER_INPUT_CH
// IO 注入(原 case 5/6/7/47
MethodIOInjectText = "io.injectText" // 5 CORE_INJECT_TEXT
MethodIOInjectInterrupt = "io.injectInterrupt" // 6 CORE_INJECT_INTERRUPT_TEXT
MethodIOInjectTextNoMem = "io.injectTextNoMem" // 7 CORE_INJECT_TEXT_NO_MEMORY
MethodIOInjectSync = "io.injectInputSync" // 47 CORE_INJECT_INPUT_SYNC
// MethodIOSetToolBlocks 多模态注入——今日 C ABI 侧是空实现§1.4
// 子进程下二进制落 arena、描述符回传首次真正可用。
MethodIOSetToolBlocks = "io.setToolBlocks"
// 生命周期(原 case 8
MethodLifecycleAutoRestart = "lifecycle.autoRestart" // 8 CORE_SET_AUTO_RESTART
// 图记忆(原 case 9/10/11/12/13
MethodMemoryRecall = "memory.recall" // 9
MethodMemoryCommit = "memory.commit" // 10
MethodMemoryIntrospect = "memory.introspect" // 11
MethodMemoryMerge = "memory.merge" // 12
MethodMemoryPurge = "memory.purge" // 13
// 文档记忆(原 case 14/32/33/34
MethodDocQuery = "doc.query" // 14
MethodDocInsert = "doc.insert" // 32
MethodDocRemove = "doc.remove" // 33
MethodDocStats = "doc.stats" // 34
// 知识库(原 case 15/35/36
MethodKnowledgeSearch = "knowledge.search" // 15
MethodKnowledgeAdd = "knowledge.add" // 35
MethodKnowledgeList = "knowledge.list" // 36
// 文本记忆(原 case 41
MethodTextMemoryAppend = "textmemory.append" // 41
// 设置(原 case 16/17/18/26/27/28/29/30/31/42/43/44/45/51
MethodSettingsGet = "settings.get" // 16
MethodSettingsSet = "settings.set" // 17
MethodSettingsRegisterDef = "settings.registerDef" // 18
MethodSettingsGetCore = "settings.getCore" // 26
MethodSettingsSetCore = "settings.setCore" // 27
MethodSettingsListCore = "settings.listCore" // 28
MethodSettingsGetPlugin = "settings.getPlugin" // 29
MethodSettingsSetPlugin = "settings.setPlugin" // 30
MethodSettingsListPlugin = "settings.listPlugin" // 31
MethodSettingsList = "settings.list" // 42
MethodSettingsDefs = "settings.defs" // 43
MethodSettingsDump = "settings.dump" // 44
MethodSettingsPlugins = "settings.plugins" // 45
MethodSettingsDataDir = "settings.dataDir" // 51
// LLM 源(原 case 19/20/37
MethodLLMListSources = "llm.listSources" // 19
MethodLLMSetSource = "llm.setSource" // 20
MethodLLMCurrentSource = "llm.currentSource" // 37
// 社交图(只读,原 case 21/22/38/39/40
MethodSocialGetPerson = "social.getPerson" // 21
MethodSocialGetNetwork = "social.getNetwork" // 22
MethodSocialGetTrait = "social.getTrait" // 38
MethodSocialGetRelation = "social.getRelations" // 39
MethodSocialListPersons = "social.listPersons" // 40
// 事件(原 case 23/24 —— 今日均为空实现「给不了」,
// 子进程下经事件环 + eventfd 首次真正可用,见 §3.6/§3.8
MethodEventsSubscribe = "events.subscribe" // 23
MethodEventsUnsubscribe = "events.unsubscribe" // 24
// 插件管理(原 case 48/49/50
MethodPluginReloadOne = "plugin.reloadOne" // 48
MethodPluginListLoaded = "plugin.listLoaded" // 49
MethodPluginIsDisabled = "plugin.isDisabled" // 50
// 共享段锁仲裁(新增,无对应 method id —— C ABI 下不存在跨进程锁概念)
MethodStageLock = "stage.lock"
MethodStageUnlock = "stage.unlock"
)
// 原 case 25CORE_FREE_STRING无对应 RPC method
// C ABI 下需要显式释放跨边界字符串,进程模型下由各自 GC 管理,概念消失。
// HandshakeParams 是内核 → 插件的建链首帧:告知内核侧规格。
type HandshakeParams struct {
Protocol int `json:"protocol"` // 内核支持的协议版本
CoreVersion string `json:"core_version"` // 内核版本(诊断用)
PluginName string `json:"plugin_name"` // 内核分配的插件名
ShmVersion uint32 `json:"shm_version"`
ShmSize int `json:"shm_size"`
// EvtRingSize 是事件环段大小0 表示不支持事件环)。插件据此 mmap fd 4。
EvtRingSize int `json:"evt_ring_size,omitempty"`
}
// HandshakeResult 是插件 → 内核的建链应答:上报自身信息。
type HandshakeResult struct {
Protocol int `json:"protocol"` // 必须等于 ProtocolVersion
SDKVersion string `json:"sdk_version"` // 插件编译时链接的公开 SDK 版本
PluginName string `json:"plugin_name"`
PID int `json:"pid"`
}
// StageInvokeParams 是 stage.invoke 的参数。
//
// **注意:不含 StageContext 数据本身**——数据在共享段,此处只带定位信息。
// 这是共享内存数据面的意义:并发改写同一份状态,而非各持副本
// (副本模型实测 35.8~36.8% lost update§8.4)。
type StageInvokeParams struct {
Stage string `json:"stage"`
// Seq 是内核写入共享段后的世代号,插件读到的 seq 应 >= 此值。
Seq uint64 `json:"seq"`
}
// StageInvokeResult 是插件执行 stage 后的应答。
type StageInvokeResult struct {
// DirtyFields 是插件实际写回共享段的字段数0 表示只读插件。
// 内核据此判断是否需要重读共享段,也用于诊断"谁改了什么"。
DirtyFields int `json:"dirty_fields"`
// Seq 是插件写回后的世代号。
Seq uint64 `json:"seq"`
}
// ToolInvokeParams / ToolInvokeResult工具调用原 go_invoke_tool
type ToolInvokeParams struct {
Name string `json:"name"`
Args map[string]interface{} `json:"args,omitempty"`
}
type ToolInvokeResult struct {
Result interface{} `json:"result,omitempty"`
}
// OutputInvokeParams输出通道发送原 go_invoke_output
//
// 与 C ABI 路径的关键差异:**可同步等待真实结果**。
// C ABI 下因 cgo 不可嵌套,只能异步 fire-and-forget导致 output_send
// 永远返回成功§9.4,现网 2 次消息发不出而模型以为成功)。
// 进程模型下 RPC 天然可等应答,该缺陷从根上消失。
type OutputInvokeParams struct {
Channel string `json:"channel"`
Args map[string]interface{} `json:"args,omitempty"`
}
// PluginInitParams插件构造参数原 case init_plugin
type PluginInitParams struct {
Name string `json:"name"`
Config map[string]interface{} `json:"config,omitempty"`
}

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,50 @@
//go:build darwin
package proc
import (
"fmt"
"os"
"golang.org/x/sys/unix"
)
// allocShm 用临时文件 + mmap 创建共享段macOS
//
// macOS 没有 memfd_create。改用 os.CreateTemp 后立即 unlink文件名从目录树消失
// 但 fd 与映射继续有效,进程退出即回收——与 memfd 的不残留语义一致。
// 已 unlink 的 fd 仍可经 ExtraFiles 传给子进程,子进程 mmap 同一 inode
// 故「全部插件共享一块段」的前提在 macOS 同样成立。
func allocShm(size int) (*os.File, []byte, error) {
f, err := os.CreateTemp("", "hastagectx-*")
if err != nil {
return nil, nil, fmt.Errorf("proc: 创建共享段临时文件: %w", err)
}
// 立即摘除目录项:后续无人能按路径打开它,也不会有残留文件
if err := os.Remove(f.Name()); err != nil {
f.Close()
return nil, nil, fmt.Errorf("proc: unlink 共享段临时文件: %w", err)
}
if err := f.Truncate(int64(size)); err != nil {
f.Close()
return nil, nil, fmt.Errorf("proc: 共享段 ftruncate: %w", err)
}
data, err := unix.Mmap(int(f.Fd()), 0, size,
unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
if err != nil {
f.Close()
return nil, nil, fmt.Errorf("proc: 共享段 mmap: %w", err)
}
return f, data, nil
}
// freeShm 解除映射并关闭段。
func freeShm(f *os.File, data []byte) error {
if data != nil {
unix.Munmap(data)
}
if f != nil {
return f.Close()
}
return nil
}

View File

@ -0,0 +1,43 @@
//go:build linux
package proc
import (
"fmt"
"os"
"golang.org/x/sys/unix"
)
// allocShm 用 memfd 创建共享段Linux
//
// 选 memfd 而非 /dev/shm 文件:无需文件名、不残留(最后一个 fd 关闭即回收)、
// 可经 ExtraFiles 传给子进程。实验 2 已验证父子 mmap 到不同虚拟地址时
// 相对偏移仍正确解引用——这是段内一律用偏移而非指针的前提。
func allocShm(size int) (*os.File, []byte, error) {
fd, err := unix.MemfdCreate("hastagectx", unix.MFD_CLOEXEC)
if err != nil {
return nil, nil, fmt.Errorf("proc: 创建共享段 memfd: %w", err)
}
if err := unix.Ftruncate(fd, int64(size)); err != nil {
unix.Close(fd)
return nil, nil, fmt.Errorf("proc: 共享段 ftruncate: %w", err)
}
data, err := unix.Mmap(fd, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
if err != nil {
unix.Close(fd)
return nil, nil, fmt.Errorf("proc: 共享段 mmap: %w", err)
}
return os.NewFile(uintptr(fd), "hastagectx"), data, nil
}
// freeShm 解除映射并关闭段。
func freeShm(f *os.File, data []byte) error {
if data != nil {
unix.Munmap(data)
}
if f != nil {
return f.Close()
}
return nil
}

View File

@ -0,0 +1,28 @@
//go:build !linux && !darwin && !windows
package proc
import (
"fmt"
"os"
)
// allocShm 在尚未适配的平台上明确报错。
//
// 不静默降级成「无共享段」:那会让 stage 静默失去数据面,
// 插件看起来加载成功但读不到 StageContext——比启动失败难查得多。
//
// Windows 适配路径CreateFileMapping + MapViewOfFile句柄经
// PROC_THREAD_ATTRIBUTE_HANDLE_LIST 或命名段传给子进程。
// §9.2 已记录 Windows DLL 路径当前能力严重退化(只下发 3 字段、无写回),
// 迁移到子进程后三套 ABI 收敛为单一 RPC 实现Windows 反而受益,但需测试机验证。
func allocShm(size int) (*os.File, []byte, error) {
return nil, nil, fmt.Errorf("proc: 当前平台尚未支持共享内存数据面(需 CreateFileMapping 适配§9.2")
}
func freeShm(f *os.File, data []byte) error {
if f != nil {
return f.Close()
}
return nil
}

View File

@ -0,0 +1,137 @@
//go:build windows
package proc
import (
"fmt"
"os"
"sync"
"sync/atomic"
"unsafe"
"golang.org/x/sys/windows"
)
// Windows 侧共享段:命名 FileMapping + 命名 Event。
//
// 与 Unix 的机制差异(不是能力差异):
// Windows 没有 fd 继承语义——os/exec 的 ExtraFiles 在 Windows 实现里不被支持。
// 等价机制是命名内核对象:父进程 CreateFileMappingW 建带名字的段,
// 子进程 OpenFileMappingW 按同名打开,拿到同一份物理页。
//
// **这是 §9.2 的正解**。C ABI 时代 Windows 是第三套独立 ABI 实现
// dynamic_dll_windows.gostage 只下发 3 字段且完全没有写回,
// sanitizer 这类改写型插件静默失效。三套 ABI 收敛为单一 RPC 后,
// Windows 与 Unix 共用同一份 stage 逻辑与同一份共享段布局,
// 平台差异只剩本文件的创建端 + 插件侧模板的打开端。
//
// 名字带 PID 与递增序号:多个 homed 实例并存时不能撞名,
// 同一实例内 StageContext 段与事件环段也必须分开。
var shmNameSeq atomic.Uint64
const (
shmNamePrefix = "Local\\HomeAgentShm"
evtRingNamePfx = "Local\\HomeAgentEvtRing"
evtEventNamePfx = "Local\\HomeAgentEvtSignal"
envStageShmName = "HOMEAGENT_SHM_STAGE"
envEvtRingName = "HOMEAGENT_SHM_EVTRING"
envEvtEventName = "HOMEAGENT_EVT_EVENT"
)
// namedShm 持有一块命名共享段。
//
// 不用 *os.File 承载Windows 的 FileMapping 句柄不是文件句柄,
// 包进 os.File 后 Close 语义不对(会尝试当文件关)。故用独立类型,
// 由 shmHandles 表按 mmap 地址反查——freeShm 只拿到 (*os.File, []byte)。
type namedShm struct {
name string
mapping windows.Handle
addr uintptr
size int
}
// shmHandles 记录已分配的段,供 freeShm 按数据指针反查句柄。
//
// 为何需要这张表allocShm 的跨平台签名返回 (*os.File, []byte)
// Windows 没有对应的 fd只能把句柄存在旁路。key 用切片首地址。
var (
shmHandles = map[uintptr]*namedShm{}
shmHandlesMu sync.Mutex
)
// allocShm 创建命名共享段并映射。
//
// 返回的 *os.File 为 nilWindows 不经 fd 传递段,插件按名字打开。
// 名字通过 procEnvForShm 注入子进程环境变量。
func allocShm(size int) (*os.File, []byte, error) {
name := fmt.Sprintf("%s_%d_%d", shmNamePrefix, os.Getpid(), shmNameSeq.Add(1))
shm, data, err := createNamedMapping(name, size)
if err != nil {
return nil, nil, err
}
shmHandlesMu.Lock()
shmHandles[uintptr(unsafe.Pointer(&data[0]))] = shm
shmHandlesMu.Unlock()
return nil, data, nil
}
// createNamedMapping 建命名段并映射为 []byte。
func createNamedMapping(name string, size int) (*namedShm, []byte, error) {
namePtr, err := windows.UTF16PtrFromString(name)
if err != nil {
return nil, nil, fmt.Errorf("proc: 共享段名字非法 %q: %w", name, err)
}
// INVALID_HANDLE_VALUE + 命名 → 由系统页文件支撑的匿名段(不落盘)
mapping, err := windows.CreateFileMapping(
windows.InvalidHandle, nil, windows.PAGE_READWRITE,
uint32(uint64(size)>>32), uint32(size), namePtr)
if err != nil {
return nil, nil, fmt.Errorf("proc: 创建命名共享段 %q: %w", name, err)
}
addr, err := windows.MapViewOfFile(mapping, windows.FILE_MAP_WRITE, 0, 0, uintptr(size))
if err != nil {
windows.CloseHandle(mapping)
return nil, nil, fmt.Errorf("proc: 映射共享段 %q: %w", name, err)
}
return &namedShm{name: name, mapping: mapping, addr: addr, size: size},
unsafe.Slice((*byte)(unsafe.Pointer(addr)), size), nil
}
// freeShm 解除映射并关闭段句柄。
func freeShm(f *os.File, data []byte) error {
if len(data) == 0 {
return nil
}
key := uintptr(unsafe.Pointer(&data[0]))
shmHandlesMu.Lock()
shm, ok := shmHandles[key]
delete(shmHandles, key)
shmHandlesMu.Unlock()
if !ok {
return nil
}
var firstErr error
if err := windows.UnmapViewOfFile(shm.addr); err != nil {
firstErr = err
}
if err := windows.CloseHandle(shm.mapping); err != nil && firstErr == nil {
firstErr = err
}
return firstErr
}
// shmNameOf 返回某块已分配段的名字(供注入子进程环境变量)。
func shmNameOf(data []byte) string {
if len(data) == 0 {
return ""
}
shmHandlesMu.Lock()
defer shmHandlesMu.Unlock()
if shm, ok := shmHandles[uintptr(unsafe.Pointer(&data[0]))]; ok {
return shm.name
}
return ""
}

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,21 @@
//go:build linux || darwin || freebsd
package proc
import "os"
// procEnvForShm 在 Unix 返回 nil段经继承 fd 传递,无需环境变量。
func (h *Host) procEnvForShm() []string { return nil }
// procExtraFilesForShm 返回经 ExtraFiles 传给子进程的 fd 列表。
//
// 顺序即 fd 编号cmd.ExtraFiles[0] → 子进程 fd 3
//
// fd 3 = StageContext 段
// fd 4 = 事件环段
// fd 5 = 事件通知eventfd / pipe 读端)
//
// 插件侧模板 z_proc_shm_unix.go 的常量与此严格对应。
func (h *Host) procExtraFilesForShm() []*os.File {
return []*os.File{h.memfd, h.evtRingFd, h.evtfd}
}

View File

@ -0,0 +1,26 @@
//go:build windows
package proc
import (
"fmt"
"os"
)
// procEnvForShm 返回子进程挂载共享段所需的环境变量Windows
//
// Windows 没有 fd 继承语义os/exec 的 ExtraFiles 在 Windows 不被支持),
// 故段与事件对象的**名字**经环境变量传给子进程,插件侧模板的
// z_proc_shm_windows.go 按同名 OpenFileMappingW / OpenEventW 打开。
//
// 名字带 PID 与递增序号:多个 homed 实例并存时不能撞名。
func (h *Host) procEnvForShm() []string {
return []string{
fmt.Sprintf("HOMEAGENT_SHM_STAGE=%s", shmNameOf(h.data)),
fmt.Sprintf("HOMEAGENT_SHM_EVTRING=%s", shmNameOf(h.evtData)),
fmt.Sprintf("HOMEAGENT_EVT_EVENT=%s", evtEventNameOf(h.evtNotifyFd)),
}
}
// procExtraFilesForShm 在 Windows 返回 nil段不经 fd 传递。
func (h *Host) procExtraFilesForShm() []*os.File { return nil }

View File

@ -0,0 +1,111 @@
package proc
import (
"context"
"fmt"
"log"
"sync"
"time"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// stage 执行:把内核的 RunStage 并发扇出接到共享段§3.4,风险 3.4 的落点)。
//
// 执行链路:
//
// 内核 RunStage并发 go func原始设计不变
// └─ 外部插件 handler = coreHandler.runStage()
// ├─ Host.beginStage首个到达者独占共享段并写入 StageContext
// ├─ stage.invoke RPC → 插件进程
// │ └─ 插件侧stage.lock → 读共享段 → handler → 只写脏字段 → stage.unlock
// └─ Host.endStage最后离开者把共享段读回内核 StageContext + 压实 arena
//
// 关键性质:
// - **并发扇出保留**§0.2 第 1 条:并发扇出是原始设计,不是缺陷)
// - **无副本**:全部插件 mmap 同一 memfd在同一份状态上读改写锁仲裁串行化临界区
// - **只读插件零写入**:脏字段集为空 → 不可能覆盖他人改写
//
// 对照今日 C ABI每个插件拿到独立 JSON 副本,回传时无条件覆盖 10 个字段,
// 实测 35.8~36.8% lost update§8.4),现网量级百分之几脏数据进 LLM§8.6)。
// lockRegistry 持有当前进行中 stage 的锁,供插件的 stage.lock/unlock 路由。
type lockRegistry struct {
mu sync.Mutex
lock *stageLock
}
func (r *lockRegistry) bind(l *stageLock) {
r.mu.Lock()
r.lock = l
r.mu.Unlock()
}
func (r *lockRegistry) current() *stageLock {
r.mu.Lock()
defer r.mu.Unlock()
return r.lock
}
func (r *lockRegistry) acquire(plugin string) error {
l := r.current()
if l == nil {
return fmt.Errorf("stage.lock: 当前无进行中的 stage插件 %s 在 stage 外加锁?)", plugin)
}
return l.Acquire(plugin)
}
func (r *lockRegistry) release(plugin string) error {
l := r.current()
if l == nil {
return fmt.Errorf("stage.unlock: 当前无进行中的 stage插件 %s", plugin)
}
return l.Release(plugin)
}
// forceRelease 在插件进程崩溃时释放其可能持有的锁(实验 9 的自愈机制)。
func (r *lockRegistry) forceRelease(plugin string) bool {
l := r.current()
if l == nil {
return false
}
return l.ForceRelease(plugin)
}
// stageInvokeTimeout 是单个插件执行 stage 的上限。
//
// 取 30s与内核工具超时60stoolcall.go留出差距
// 使 stage 超时能被识别为 stage 问题而非工具问题。
// 超时后调用方返回错误,卡住的插件进程可由上层 Kill 回收——
// **对比 cgo 路径超时后 OS 线程永久泄漏(现网 26 次§9.3**。
const stageInvokeTimeout = 30 * time.Second
// runStage 是注册到内核 StageHost 的 handler每个外部插件一个
func (h *coreHandler) runStage(stage string, sc *pubsdk.StageContext) error {
if h.host == nil {
return fmt.Errorf("插件 %s: stage %s 共享段未就绪", h.name, stage)
}
coord, err := h.host.beginStage(sc)
if err != nil {
return fmt.Errorf("插件 %s stage %s: %w", h.name, stage, err)
}
defer func() {
if endErr := h.host.endStage(coord); endErr != nil {
log.Printf("[proc] %s stage %s 收尾失败: %v", h.name, stage, endErr)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), stageInvokeTimeout)
defer cancel()
if err := h.invokeStageWithCtx(ctx, stage, coord.seg.Seq()); err != nil {
// 插件可能在持锁时失败(崩溃/超时)——强制释放,避免后续插件死锁。
// 这正是"锁仲裁回内核"的自愈价值(实验 9无需 robust mutex。
if h.host.ForceReleaseLock(h.name) {
log.Printf("[proc] %s stage %s 失败后强制释放其持有的 stage 锁", h.name, stage)
}
return err
}
return nil
}

View File

@ -0,0 +1,157 @@
package proc
import (
"sync"
"sync/atomic"
"testing"
"time"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// 流式输出压测§4.3 标记「风险高」的那一项)。
//
// 担忧的原文「Bus.Publish 路径禁用任何锁/阻塞——流式输出逐 token 发布,
// 任何等待都会卡顿」。实验 4 的数据:同步 Publish + 一个 20µs 慢订阅者,
// 5000 token 耗时 5.07s;改为写环 + post 后 2.29ms(加速比 2218x
//
// 这里验证事件环侧的 post-and-forget 性质在实现中成立。
// 慢消费者不拖慢 Publish。
//
// 判据:若 Publish 等消费者5000 × 20µs = 100ms 是理论下限。
// post-and-forget 应远低于此。
func TestStreaming_SlowConsumerDoesNotBlockPublish(t *testing.T) {
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
ring := host.EvtRing()
var consumed atomic.Int64
consumer := NewEvtConsumer(host.EvtData(), host.EvtfdReadFile(), 0,
func(evt *pubsdk.Event) error {
time.Sleep(20 * time.Microsecond) // 刻意的慢订阅者
consumed.Add(1)
return nil
})
go consumer.Run()
defer consumer.Stop()
const tokens = 5000
payload := []byte(`{"type":"content_delta","payload":{"text":"t"}}`)
start := time.Now()
for i := 0; i < tokens; i++ {
ring.WritePush(pubsdk.EventContentDelta, payload)
EvtfdNotify(host.EvtNotifyFd())
}
elapsed := time.Since(start)
perToken := elapsed / tokens
t.Logf("%d 次 Publish 耗时 %v均摊 %v/token消费者每条睡 20µs",
tokens, elapsed, perToken)
t.Logf("同步语义下的理论下限:%v", tokens*20*time.Microsecond)
if elapsed > 100*time.Millisecond {
t.Errorf("Publish 疑似被慢消费者阻塞:耗时 %v ≥ 同步下限 100ms", elapsed)
}
if perToken > 20*time.Microsecond {
t.Errorf("均摊 %v/token ≥ 消费者处理时间 20µs说明存在等待", perToken)
}
}
// 订阅者增多不使 Publish 线性恶化。
//
// §4.3 的具体要求:「长回复下 Publish 单次耗时不随订阅者数线性恶化」。
func TestStreaming_PublishLatencyFlatAcrossSubscribers(t *testing.T) {
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
ring := host.EvtRing()
payload := []byte(`{"type":"content_delta","payload":{"text":"t"}}`)
const rounds = 3000
measure := func(consumers int) time.Duration {
var wg sync.WaitGroup
active := make([]*EvtConsumer, 0, consumers)
for i := 0; i < consumers; i++ {
c := NewEvtConsumer(host.EvtData(), host.EvtfdReadFile(), 0,
func(evt *pubsdk.Event) error {
time.Sleep(10 * time.Microsecond)
return nil
})
active = append(active, c)
wg.Add(1)
go func(cc *EvtConsumer) {
defer wg.Done()
cc.Run()
}(c)
}
defer func() {
for _, c := range active {
c.Stop()
}
}()
// 让消费者先就位
time.Sleep(10 * time.Millisecond)
start := time.Now()
for i := 0; i < rounds; i++ {
ring.WritePush(pubsdk.EventContentDelta, payload)
EvtfdNotify(host.EvtNotifyFd())
}
return time.Since(start)
}
d1 := measure(1)
d8 := measure(8)
t.Logf("1 个消费者:%v均摊 %v/次)", d1, d1/rounds)
t.Logf("8 个消费者:%v均摊 %v/次)", d8, d8/rounds)
// 线性恶化的判据8 倍订阅者不应接近 8 倍耗时。
// 阈值取 4 倍——测量噪声与调度抖动都会影响。
if d8 > d1*4 {
t.Errorf("订阅者 1→8Publish 从 %v 涨到 %v>4 倍),疑似线性恶化", d1, d8)
}
}
// 事件环溢出时 Publish 不退化。
//
// 消费者完全停摆时写端会覆盖最旧 slot。这条路径必须仍是 O(1)
// 否则「消费者卡住」会连带拖慢内核主循环。
func TestStreaming_PublishStaysFastWhenRingOverflows(t *testing.T) {
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
ring := host.EvtRing()
payload := []byte(`{"type":"content_delta","payload":{"text":"t"}}`)
// 无消费者环必然溢出cap=8192
const rounds = 30000
start := time.Now()
for i := 0; i < rounds; i++ {
ring.WritePush(pubsdk.EventContentDelta, payload)
}
elapsed := time.Since(start)
perPush := elapsed / rounds
t.Logf("无消费者写入 %d 次(环 cap=%d必然溢出%v均摊 %v/次",
rounds, evtRingCap, elapsed, perPush)
// 溢出路径仍应是亚微秒级
if perPush > 5*time.Microsecond {
t.Errorf("溢出时均摊 %v/次,超出预期(应亚微秒级)", perPush)
}
}

View File

@ -0,0 +1,163 @@
package proc
import (
"fmt"
"log"
"sort"
"sync"
"time"
)
// Supervisor 是内核侧**唯一**的子进程台账。
//
// 为什么必须有它,而不是让每个 Plugin 各自管好自己的 Process
//
// 1. **没有台账就没有"全部子进程"这个概念**。内核关停时只能遍历 registry 的
// 插件表逐个 Stop而 registry 表是按插件名索引的——握手失败、Start 中途
// 出错、或刚 spawn 还没进表就崩了的进程registry 根本不知道它们存在,
// 那些进程会变成孤儿ppid=1继续跑还持有共享段映射。
// 2. **诊断面缺失**。此前 `/api/manager/status` 之类的接口拿不到"实跑几个子进程、
// 各自 PID 多少、活了多久、崩过几次",运维只能 ps | grep。
// 3. **收割保证**。每个 Process 自带一根 waitLoop 立即 wait4(2)Supervisor
// 只负责登记/注销与聚合视图;两者配合才能做到"进程一死内核立刻知道"。
//
// 生命周期Spawn 成功握手后 trackProcess.markExited 里 untrack。
type Supervisor struct {
mu sync.RWMutex
procs map[string]*Process
// closed 后拒绝新的 track防止关停竞态里又冒出新进程。
closed bool
}
// NewSupervisor 创建空台账。
func NewSupervisor() *Supervisor {
return &Supervisor{procs: make(map[string]*Process)}
}
// track 登记一个已握手成功的子进程。
//
// 同名覆盖是正常情况(重载:旧进程 untrack 早于或晚于新进程 track 都可能,
// 取决于 Kill 与 Spawn 的交错),故不报错,只在真覆盖时留日志。
func (s *Supervisor) track(p *Process) {
if p == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
// 关停途中还有进程完成握手:立即结束它,不让它活过内核。
go p.Kill()
return
}
if old, ok := s.procs[p.name]; ok && old != p {
log.Printf("[proc] 台账中 %s 已有 pid=%d被 pid=%d 覆盖", p.name, old.PID(), p.PID())
}
s.procs[p.name] = p
}
// untrack 注销(进程已退出)。只有当表里那一项确实是它时才删,
// 避免重载时新进程被旧进程的退出回调误删。
func (s *Supervisor) untrack(name string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.procs, name)
}
// Get 按插件名取子进程句柄。
func (s *Supervisor) Get(name string) (*Process, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
p, ok := s.procs[name]
return p, ok
}
// Count 返回在册子进程数。
func (s *Supervisor) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.procs)
}
// ProcInfo 是单个子进程的运行期快照。
type ProcInfo struct {
Name string `json:"name"`
PID int `json:"pid"`
Alive bool `json:"alive"`
Bin string `json:"bin"`
}
// List 返回全部在册子进程的快照(按插件名排序,便于稳定展示)。
func (s *Supervisor) List() []ProcInfo {
s.mu.RLock()
out := make([]ProcInfo, 0, len(s.procs))
for name, p := range s.procs {
alive := true
select {
case <-p.Exited():
alive = false
default:
}
out = append(out, ProcInfo{Name: name, PID: p.PID(), Alive: alive, Bin: p.bin})
}
s.mu.RUnlock()
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// StopAll 停止全部在册子进程:先并发发 plugin.stop 走优雅路径,
// 到期仍在的一律 Kill。
//
// 这是内核关停时**必须**调的:不调则子进程被 init 收养成孤儿,
// 继续持有共享段映射(段已被内核 unmap它们下次访问就是 SIGBUS
// 并且下次 homed 启动时同名插件会与残留进程抢同一份外部资源
// qq 的 WS 连接、browser 的 chromium profile 锁)。
func (s *Supervisor) StopAll(timeout time.Duration) {
s.mu.Lock()
s.closed = true
procs := make([]*Process, 0, len(s.procs))
for _, p := range s.procs {
procs = append(procs, p)
}
s.mu.Unlock()
if len(procs) == 0 {
return
}
log.Printf("[proc] 关停 %d 个子进程插件", len(procs))
var wg sync.WaitGroup
for _, p := range procs {
wg.Add(1)
go func(pr *Process) {
defer wg.Done()
if err := pr.Stop(); err != nil {
log.Printf("[proc] 停止 %s: %v", pr.Name(), err)
}
}(p)
}
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
if timeout <= 0 {
timeout = stopGracePeriod * 2
}
select {
case <-done:
case <-time.After(timeout):
// 优雅停止没在预算内完成:剩下的直接 Kill。
// 不能无限等——homed 关停被单个卡住的插件拖住比杀掉它更糟。
var stuck []string
for _, p := range procs {
select {
case <-p.Exited():
default:
stuck = append(stuck, fmt.Sprintf("%s(pid=%d)", p.Name(), p.PID()))
go p.Kill()
}
}
if len(stuck) > 0 {
log.Printf("[proc] %v 内未优雅退出,强制结束: %v", timeout, stuck)
}
}
}

View File

@ -0,0 +1,232 @@
//go:build ignore
// appendplugin 在 stage 中把自己的标记追加到 FinalText读-改-写)。
//
// 用于跨进程 lost update 验证:多个此类插件并发处理同一 stage
// 若全部标记都保留 ⇒ 无丢失;若少了 ⇒ 出现 lost update。
//
// 这是实验 85 进程 × 300 轮零丢失)在真实 RPC + 真实内核 RunStage
// 下的复刻——机制单测已过,这里验证集成后同样成立。
package main
import (
"bufio"
"encoding/binary"
"encoding/json"
"fmt"
"os"
"sync"
"syscall"
)
type request struct {
ID uint64 `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type response struct {
ID uint64 `json:"id"`
Result interface{} `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
var (
out = bufio.NewWriter(os.Stdout)
writeMu sync.Mutex
nextID uint64
pendMu sync.Mutex
pending = map[uint64]chan json.RawMessage{}
shm []byte
tag string
)
func send(v interface{}) {
b, _ := json.Marshal(v)
writeMu.Lock()
out.Write(b)
out.WriteByte('\n')
out.Flush()
writeMu.Unlock()
}
func callKernel(method string, params interface{}) (json.RawMessage, bool) {
pendMu.Lock()
nextID++
id := nextID
ch := make(chan json.RawMessage, 1)
pending[id] = ch
pendMu.Unlock()
var raw json.RawMessage
if params != nil {
b, _ := json.Marshal(params)
raw = b
}
send(request{ID: id, Method: method, Params: raw})
r, ok := <-ch
return r, ok
}
const (
headerSize = 64
stageFieldCount = 18
sliceSize = 8
flagCount = 8
offArenaBase = 8
offArenaCap = 12
offArenaUsed = 16
offCtxBase = 20
offSeq = 24
fFinalText = 5 // 与 proc/shmcodec.go 的 stageField 枚举顺序一致
)
func desc(field int) (uint32, uint32) {
cb := binary.LittleEndian.Uint32(shm[offCtxBase:])
o := cb + uint32(field*sliceSize)
return binary.LittleEndian.Uint32(shm[o:]), binary.LittleEndian.Uint32(shm[o+4:])
}
func setDesc(field int, off, ln uint32) {
cb := binary.LittleEndian.Uint32(shm[offCtxBase:])
o := cb + uint32(field*sliceSize)
binary.LittleEndian.PutUint32(shm[o:], off)
binary.LittleEndian.PutUint32(shm[o+4:], ln)
}
func readFinalText() string {
off, ln := desc(fFinalText)
if off == 0 && ln == 0 {
return ""
}
if ln == 0 {
return ""
}
base := binary.LittleEndian.Uint32(shm[offArenaBase:])
return string(shm[base+off : base+off+ln])
}
func writeFinalText(s string) error {
used := binary.LittleEndian.Uint32(shm[offArenaUsed:])
if used == 0 {
used = 1
}
cap_ := binary.LittleEndian.Uint32(shm[offArenaCap:])
if used+uint32(len(s)) > cap_ {
return fmt.Errorf("arena 空间不足")
}
base := binary.LittleEndian.Uint32(shm[offArenaBase:])
copy(shm[base+used:], []byte(s))
binary.LittleEndian.PutUint32(shm[offArenaUsed:], used+uint32(len(s)))
setDesc(fFinalText, used, uint32(len(s)))
// 世代号自增
v := binary.LittleEndian.Uint64(shm[offSeq:])
binary.LittleEndian.PutUint64(shm[offSeq:], v+1)
return nil
}
func main() {
tag = os.Getenv("PLUGIN_TAG")
if tag == "" {
tag = "?"
}
in := bufio.NewScanner(bufio.NewReader(os.Stdin))
in.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for in.Scan() {
line := make([]byte, len(in.Bytes()))
copy(line, in.Bytes())
var probe struct {
ID uint64 `json:"id"`
Method string `json:"method"`
}
if json.Unmarshal(line, &probe) != nil {
continue
}
if probe.Method == "" {
var resp struct {
ID uint64 `json:"id"`
Result json.RawMessage `json:"result"`
}
json.Unmarshal(line, &resp)
pendMu.Lock()
ch, ok := pending[resp.ID]
delete(pending, resp.ID)
pendMu.Unlock()
if ok {
ch <- resp.Result
}
continue
}
var req request
json.Unmarshal(line, &req)
switch req.Method {
case "handshake":
var hp struct {
ShmSize int `json:"shm_size"`
}
json.Unmarshal(req.Params, &hp)
if hp.ShmSize > 0 {
m, err := syscall.Mmap(3, 0, hp.ShmSize,
syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED)
if err != nil {
send(response{ID: req.ID, Error: fmt.Sprintf("mmap: %v", err)})
continue
}
shm = m
}
send(response{ID: req.ID, Result: map[string]interface{}{
"protocol": 1, "sdk_version": "test",
"plugin_name": "append-" + tag, "pid": os.Getpid(),
}})
case "plugin.init":
send(response{ID: req.ID})
case "plugin.start":
go func(id uint64) {
callKernel("stage.register", map[string]interface{}{
"stage": "after_toolcall",
"scope": "global",
})
send(response{ID: id})
}(req.ID)
case "plugin.stop":
send(response{ID: req.ID})
out.Flush()
os.Exit(0)
case "stage.invoke":
go func(id uint64) {
if shm == nil {
send(response{ID: id, Error: "共享段未挂载"})
return
}
// 拿锁 → 读 → 追加自己的标记 → 写回 → 放锁
callKernel("stage.lock", nil)
cur := readFinalText()
err := writeFinalText(cur + tag)
callKernel("stage.unlock", nil)
if err != nil {
send(response{ID: id, Error: err.Error()})
return
}
send(response{ID: id, Result: map[string]interface{}{"dirty_fields": 1}})
}(req.ID)
default:
if req.ID != 0 {
send(response{ID: req.ID})
}
}
}
}

View File

@ -0,0 +1,52 @@
//go:build ignore
// badprotoplugin 上报错误的协议版本,验证内核显式拒绝而非半兼容运行。
package main
import (
"bufio"
"encoding/json"
"os"
)
type request struct {
ID uint64 `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type response struct {
ID uint64 `json:"id"`
Result interface{} `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
func main() {
in := bufio.NewScanner(bufio.NewReader(os.Stdin))
out := bufio.NewWriter(os.Stdout)
send := func(v interface{}) {
b, _ := json.Marshal(v)
out.Write(b)
out.WriteByte('\n')
out.Flush()
}
for in.Scan() {
var req request
if err := json.Unmarshal(in.Bytes(), &req); err != nil {
continue
}
if req.Method == "handshake" {
send(response{ID: req.ID, Result: map[string]interface{}{
"protocol": 999, // 故意不匹配
"sdk_version": "ancient",
"plugin_name": "badproto",
"pid": os.Getpid(),
}})
continue
}
if req.ID != 0 {
send(response{ID: req.ID})
}
}
}

View File

@ -0,0 +1,120 @@
//go:build ignore
// callbackplugin 验证插件 → 内核的反向调用51 个 core.* method 的机制)。
package main
import (
"bufio"
"encoding/json"
"os"
"sync"
)
type request struct {
ID uint64 `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type response struct {
ID uint64 `json:"id"`
Result interface{} `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
var (
out = bufio.NewWriter(os.Stdout)
writeMu sync.Mutex
nextID uint64
pending = map[uint64]chan json.RawMessage{}
pendMu sync.Mutex
)
func send(v interface{}) {
b, _ := json.Marshal(v)
writeMu.Lock()
out.Write(b)
out.WriteByte('\n')
out.Flush()
writeMu.Unlock()
}
// callKernel 反向调用内核并等待应答。
func callKernel(method string, params interface{}) json.RawMessage {
pendMu.Lock()
nextID++
id := nextID
ch := make(chan json.RawMessage, 1)
pending[id] = ch
pendMu.Unlock()
var raw json.RawMessage
if params != nil {
b, _ := json.Marshal(params)
raw = b
}
send(request{ID: id, Method: method, Params: raw})
return <-ch
}
func main() {
in := bufio.NewScanner(bufio.NewReader(os.Stdin))
in.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for in.Scan() {
line := make([]byte, len(in.Bytes()))
copy(line, in.Bytes())
var probe struct {
ID uint64 `json:"id"`
Method string `json:"method"`
}
if err := json.Unmarshal(line, &probe); err != nil {
continue
}
// 内核对我们反向调用的应答
if probe.Method == "" {
var resp struct {
ID uint64 `json:"id"`
Result json.RawMessage `json:"result"`
}
json.Unmarshal(line, &resp)
pendMu.Lock()
ch, ok := pending[resp.ID]
delete(pending, resp.ID)
pendMu.Unlock()
if ok {
ch <- resp.Result
}
continue
}
var req request
json.Unmarshal(line, &req)
switch req.Method {
case "handshake":
send(response{ID: req.ID, Result: map[string]interface{}{
"protocol": 1,
"sdk_version": "test",
"plugin_name": "cb",
"pid": os.Getpid(),
}})
case "plugin.start":
// 在独立 goroutine 里回调,避免阻塞读循环
go func(id uint64) {
callKernel("tool.register", map[string]interface{}{"name": "cb_tool"})
callKernel("settings.get", map[string]interface{}{"key": "some_key"})
send(response{ID: id})
}(req.ID)
case "plugin.stop":
send(response{ID: req.ID})
out.Flush()
os.Exit(0)
default:
if req.ID != 0 {
send(response{ID: req.ID})
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More