mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-24 10:58:13 +00:00
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:
17
.gitignore
vendored
17
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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 |
|
||||
|-----------|-----------|
|
||||
|
||||
@ -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 继承 fd,Windows 命名内核对象)。
|
||||
|
||||
### 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)`)**
|
||||
|
||||
| 子表 | 函数 |
|
||||
|------|------|
|
||||
|
||||
@ -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 {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存对已有连接的修改。
|
||||
*
|
||||
* 修改当前生效的连接后必须重新 setConnection:ApiClient 持有的是
|
||||
* 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/url:ForEach 对相同键只更新绑定、不重跑 @Builder 体,
|
||||
// 只用 id 做键时改完地址这一行还显示旧值。行内没有 TextInput,
|
||||
// 因此把可变字段放进键不会有"编辑时焦点被销毁"的副作用。
|
||||
}, (conn: ConnectionConfig) => conn.id + '|' + conn.name + '|' + conn.url)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
155
docs/git-branching.md
Normal file
155
docs/git-branching.md
Normal file
@ -0,0 +1,155 @@
|
||||
# Git 分支管理规范
|
||||
|
||||
> 生效:2026-08-31。适用:**本仓(TrueAgent/HomeAgent)与 third_party/homeagent-sdk(SDK 仓)**——两仓协作时分支策略必须一致,本规范两仓同用。
|
||||
> 核心原则一句话:**main 唯一长命、永远可部署;一切新工作在特性分支;版本发布走 release 分支 + tag;hotfix 只进 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-pick(hotfix 逐个 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,二选一在团队内固定)。
|
||||
- 多人:走 PR(review 后合入)。
|
||||
- 合回后删除 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 1:Part 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` §七)。
|
||||
@ -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 shim(shim 本身常驻,无所谓)
|
||||
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(" => 句柄不同:真正卸载并重新装载了新代码 ✅")
|
||||
}
|
||||
}
|
||||
@ -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✅ 卸载成功")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
#include <stdio.h>
|
||||
const char* probe_version(void){ return "V1"; }
|
||||
@ -0,0 +1,2 @@
|
||||
#include <stdio.h>
|
||||
const char* probe_version(void){ return "V2"; }
|
||||
@ -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); }
|
||||
49
docs/zh/experiments/plugin-arch/02-feasibility/exp10.go
Normal file
49
docs/zh/experiments/plugin-arch/02-feasibility/exp10.go
Normal 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)
|
||||
}
|
||||
}
|
||||
42
docs/zh/experiments/plugin-arch/02-feasibility/exp11.go
Normal file
42
docs/zh/experiments/plugin-arch/02-feasibility/exp11.go
Normal 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)
|
||||
}
|
||||
12
docs/zh/experiments/plugin-arch/02-feasibility/exp11_plug.go
Normal file
12
docs/zh/experiments/plugin-arch/02-feasibility/exp11_plug.go
Normal 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() }
|
||||
}
|
||||
@ -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("=== 实验 1:eventfd 是否走 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())
|
||||
}
|
||||
41
docs/zh/experiments/plugin-arch/02-feasibility/exp2_child.go
Normal file
41
docs/zh/experiments/plugin-arch/02-feasibility/exp2_child.go
Normal 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")
|
||||
}
|
||||
@ -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]))
|
||||
}
|
||||
}
|
||||
31
docs/zh/experiments/plugin-arch/02-feasibility/exp3_child.go
Normal file
31
docs/zh/experiments/plugin-arch/02-feasibility/exp3_child.go
Normal 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))
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
71
docs/zh/experiments/plugin-arch/02-feasibility/exp4.go
Normal file
71
docs/zh/experiments/plugin-arch/02-feasibility/exp4.go
Normal 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 使流式发布与消费者速度解耦")
|
||||
}
|
||||
}
|
||||
55
docs/zh/experiments/plugin-arch/02-feasibility/exp5.go
Normal file
55
docs/zh/experiments/plugin-arch/02-feasibility/exp5.go
Normal 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("=== 实验 5:17 个 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() }
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
68
docs/zh/experiments/plugin-arch/02-feasibility/exp5b.go
Normal file
68
docs/zh/experiments/plugin-arch/02-feasibility/exp5b.go
Normal 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("=== 实验 5b:17 个 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() }
|
||||
}
|
||||
49
docs/zh/experiments/plugin-arch/02-feasibility/exp6.go
Normal file
49
docs/zh/experiments/plugin-arch/02-feasibility/exp6.go
Normal 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")
|
||||
}
|
||||
23
docs/zh/experiments/plugin-arch/02-feasibility/exp6_crash.go
Normal file
23
docs/zh/experiments/plugin-arch/02-feasibility/exp6_crash.go
Normal 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()
|
||||
}
|
||||
}
|
||||
63
docs/zh/experiments/plugin-arch/02-feasibility/exp7.go
Normal file
63
docs/zh/experiments/plugin-arch/02-feasibility/exp7.go
Normal 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")
|
||||
}
|
||||
}
|
||||
84
docs/zh/experiments/plugin-arch/02-feasibility/exp8.go
Normal file
84
docs/zh/experiments/plugin-arch/02-feasibility/exp8.go
Normal 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")
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
60
docs/zh/experiments/plugin-arch/02-feasibility/exp9.go
Normal file
60
docs/zh/experiments/plugin-arch/02-feasibility/exp9.go
Normal 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
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
90
docs/zh/experiments/plugin-arch/03-lost-update/exp12/main.go
Normal file
90
docs/zh/experiments/plugin-arch/03-lost-update/exp12/main.go
Normal 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 }
|
||||
|
||||
// 插件 handler:ctx.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 非原子:快照与写回之间的窗口导致覆盖")
|
||||
}
|
||||
101
docs/zh/experiments/plugin-arch/03-lost-update/exp13/main.go
Normal file
101
docs/zh/experiments/plugin-arch/03-lost-update/exp13/main.go
Normal 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_query,sanitizer+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")
|
||||
}
|
||||
}
|
||||
@ -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 回收全部资源")
|
||||
}
|
||||
@ -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 独占一个 M(OS 线程),无法被抢占或回收")
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
#include <unistd.h>
|
||||
void hang_forever(void) { while(1) sleep(1); }
|
||||
137
docs/zh/experiments/plugin-arch/19-migration-verify/README.md
Normal file
137
docs/zh/experiments/plugin-arch/19-migration-verify/README.md
Normal 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-02,15 个真实插件)
|
||||
|
||||
```
|
||||
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.8MB(browser 依赖最多)。
|
||||
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 而非 fail,CI 不强制先跑重编脚本。
|
||||
|
||||
## 压测与延迟(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-02,AMD 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µs(2.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
|
||||
同步语义理论下限 100ms(5000 × 20µs)
|
||||
|
||||
订阅者 1 个:1.547ms(515 ns/次)
|
||||
订阅者 8 个:1.518ms(506 ns/次) ← 几乎不变,无线性恶化
|
||||
|
||||
环溢出(无消费者写 30000 次,cap=8192):均摊 35 ns/次 ← 仍 O(1)
|
||||
```
|
||||
|
||||
2.29ms 与实验 4 的数字完全一致(那次也是 2.29ms / 0.46µs per token)——
|
||||
post-and-forget 在实现中成立。
|
||||
|
||||
最后一项的意义:消费者完全停摆时写端覆盖最旧 slot,这条路径仍是 O(1),
|
||||
故「消费者卡住」不会连带拖慢内核主循环。
|
||||
@ -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.2MB(browser 依赖最多)。"
|
||||
echo " RSS 随二进制体积线性增长,故不可直接与基线数字比较——"
|
||||
echo " 要比的是「均摊线程数」与「PSS/RSS 比值(共享收益)」这两个结构性指标。"
|
||||
56
docs/zh/experiments/plugin-arch/19-migration-verify/rebuild-plugins.sh
Executable file
56
docs/zh/experiments/plugin-arch/19-migration-verify/rebuild-plugins.sh
Executable 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)
|
||||
132
docs/zh/experiments/plugin-arch/19-migration-verify/switch-production.py
Executable file
132
docs/zh/experiments/plugin-arch/19-migration-verify/switch-production.py
Executable file
@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""生产切换:经 pluginmgr 正规通道安装 17 个 hmap(Part 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>.hmap(qq 是这种)
|
||||
"""
|
||||
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())
|
||||
111
docs/zh/experiments/plugin-arch/README.md
Normal file
111
docs/zh/experiments/plugin-arch/README.md
Normal 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-31,go1.25.12 linux/amd64,192.168.2.60(12 核)。
|
||||
|
||||
### 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 loop(2.68MB),真实插件(如 qq 7.5MB)开销更高
|
||||
- 无 Windows 环境,9.2 的 Windows DLL 缺陷**未经实测**,仅代码阅读
|
||||
127
docs/zh/experiments/plugin-arch/run.sh
Executable file
127
docs/zh/experiments/plugin-arch/run.sh
Executable 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 ]
|
||||
317
docs/zh/plugin-interface-matrix.md
Normal file
317
docs/zh/plugin-interface-matrix.md
Normal 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.so(C ABI bridge:51 个整数 method id)
|
||||
之后: 外部插件 = example/*/plugin.go(纯 Go,一行不改) ──plugindev go build──> plugin.bin
|
||||
homed ──spawn──> plugin.bin(stdio 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 47(例:qq 闭环) |
|
||||
| `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/Arguments;CallID/Name/Plugin/Success/Result;Role/Content/Score | 全部纯 JSON 可序列化 |
|
||||
| `ContentBlock` / `ImageURL` / `AudioURL` | Type/Text/ImageURL/AudioURL;URL/Detail;URL | 全部可偏移化(迁移评估 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)。
|
||||
|
||||
---
|
||||
|
||||
## 三、合同面 B:bridge 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` | 删除 |
|
||||
|
||||
---
|
||||
|
||||
## 四、合同面 C:StageContext 跨 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` 多模态注入 | ❌ 空实现 | ✅ 二进制落 arena,Slice 描述符回传 | ⚠️ 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/` 迁移执行期工具
|
||||
632
docs/zh/plugin-migration-plan.md
Normal file
632
docs/zh/plugin-migration-plan.md
Normal 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.4,S)
|
||||
|
||||
> 依据:迁移评估 §2.4 / 3.2;plan.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 三种 manifest(so/dll/bin/lua)→ 分派到正确通道;`.bin` 桩返回明确错误而非 panic。
|
||||
- 【V】既有 `.so` 插件加载 e2e 不回归(带一个真实 .so 冒烟)。
|
||||
|
||||
**Part 1 出口条件**:分派骨架在,`.bin` 有明确桩位,`.so` 全回归。
|
||||
#### ✅ **Part 1 已完成**(2026-08-31,commit `610e9d0`)
|
||||
|
||||
- 【M】✅ `dynamic.go`:新增 `binEntry`/`skillEntry` 常量 + `entryKind` 枚举 + `classifyEntry` / `detectEntryKind`
|
||||
- **manifest 的 entry 优先级最高**——把 entry 改回 `plugin.so` 即回退 cabi 通道(回退路径的保证)
|
||||
- 无 manifest 时按目录探测,`.bin` 优先于 `.so`(迁移期同目录两产物共存时走新通道)
|
||||
- 【M】✅ `registry.go` `tryDynamic`:按 entry 分派 proc/cabi;entry 声明 `.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-01,commit `d62430a` + `82dcc86`)
|
||||
|
||||
- `proc/protocol.go`:NDJSON 帧、**51 个 method id 平移为 method 名**(编号扔掉)、握手/stage/tool/output 参数类型。
|
||||
`case 25`(CORE_FREE_STRING) 无对应 method(GC 接管);`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 3:plugindev 工具链改造(阶段 2.6/2.7/2.8,M,SDK 仓)
|
||||
|
||||
> 依据:合同面 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-02,SDK 仓 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}` 偏移描述符 + arena(append-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】复刻实验 8:5 子进程 × 300 轮并发改写 → **零丢失零撕裂**。
|
||||
- 【V】复刻实验 13 现网场景:sanitizer(改 ToolResults)+ weather(只读)并发 → 清洗结果不再被覆盖。
|
||||
- 【V】改写型插件行为基线测试:`sanitizer`/`multimodal` 迁移前后行为对拍(迁移评估 §4.4 风险缓解)。
|
||||
|
||||
**Part 4 出口条件**:跨进程并发改写零丢失,内置/外置语义一致,16 字段全可见。
|
||||
#### ✅ **Part 4 核心已完成**(2026-08-31,commit `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-01~09-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` schema(write_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】复刻实验 4:post-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 runtime,15 个不同二进制无共同物理页可映射(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 | 内存/延迟 | 常驻 +≤29MB,RPC 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 runtime,15 个不同二进制无共同物理页(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-31,update 分支。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 个 hmap(overwrite=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 个残留 .so;17 个 plugin.bin 均有执行位
|
||||
17 个 manifest 的 entry 均为 plugin.bin;无 .bak 残留
|
||||
bundle 包正确挑了当前平台(weather 目录只留 8.7MB 的 linux/amd64 那份)
|
||||
```
|
||||
|
||||
备份:`/home/newqqagent-migration-backup-20260902-214812`
|
||||
(plugins 全目录 + homed.old + homeagent.service,162MB)。
|
||||
**唯一回滚路径**是恢复该目录 + 回滚 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.547ms(515 ns/次)
|
||||
订阅者 8 个:1.518ms(506 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.0(tag 已打)。公开 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 要同步维护那张整数表。
|
||||
1621
docs/zh/架构迁移评估.md
Normal file
1621
docs/zh/架构迁移评估.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
96
internal/agent/core/stages_plugin_test.go
Normal file
96
internal/agent/core/stages_plugin_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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=800;v0.9.x → CABINum=900(invoke_stage 写回)。
|
||||
// 小版本(patch)演进不影响 ABI,CABINum 不变。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 字段
|
||||
//
|
||||
// 保留那些常量只会让人以为它们还在生效。
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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+minor,v0.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); }
|
||||
@ -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→800,v0.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)
|
||||
}
|
||||
}
|
||||
@ -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 是字符串 semver(var 转发,因 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
|
||||
)
|
||||
249
internal/plugin/crash_recovery_test.go
Normal file
249
internal/plugin/crash_recovery_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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 会 fatal(no plugin module data),
|
||||
// 因此不再 fallback 到 Go plugin,直接返回加载错误避免崩溃。
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var _ = json.Marshal
|
||||
@ -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
|
||||
}
|
||||
254
internal/plugin/dynamic_proc_unix.go
Normal file
254
internal/plugin/dynamic_proc_unix.go
Normal 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 §3(stdio 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 异常退出: %v(homed 未受影响)", 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 同一个
|
||||
// readLoop(Process.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 次→1s,2 次→2s,3 次→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)
|
||||
}
|
||||
25
internal/plugin/dynamic_proc_windows.go
Normal file
25
internal/plugin/dynamic_proc_windows.go
Normal file
@ -0,0 +1,25 @@
|
||||
//go:build windows
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// tryLoadProc 的 Windows 桩:子进程通道本身是跨平台的(stdio JSON-RPC 无平台差异),
|
||||
// 但共享内存数据面当前基于 POSIX mmap,Windows 需改用 CreateFileMapping。
|
||||
//
|
||||
// 迁移评估 §9.2 已记录:Windows DLL 路径当前能力严重退化(只下发 3 字段、无写回),
|
||||
// 迁移到子进程后三套 ABI 收敛为单一 RPC 实现,Windows 反而受益——但需要测试机验证。
|
||||
func tryLoadProc(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
for _, candidate := range []string{binEntry, "plugin.exe"} {
|
||||
if st, err := os.Stat(filepath.Join(dir, candidate)); err == nil && !st.IsDir() {
|
||||
return nil, fmt.Errorf("proc plugin %s: Windows 子进程通道尚未实现(Part 2 + §9.2)", name)
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
200
internal/plugin/entry_dispatch_test.go
Normal file
200
internal/plugin/entry_dispatch_test.go
Normal 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 时应为 unknown(C 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)
|
||||
}
|
||||
}
|
||||
55
internal/plugin/evtring.go
Normal file
55
internal/plugin/evtring.go
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
160
internal/plugin/evtring_test.go
Normal file
160
internal/plugin/evtring_test.go
Normal 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_call(bit 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 被过滤
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
222
internal/plugin/proc/bench_test.go
Normal file
222
internal/plugin/proc/bench_test.go
Normal 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.29ms(5000 token + 20µs 慢消费者)
|
||||
// 实验 11 工具调用 RPC p50 19.6 µs
|
||||
//
|
||||
// 这些基准回答的是「进程边界的代价是否可忽略」——相对 stage handler 的实际
|
||||
// 工作量(LLM 往返 2-8 秒),微秒级往返不构成问题;但若退化到毫秒级,
|
||||
// 高频工具调用就会被感知。
|
||||
//
|
||||
// 实测结果(2026-09-02,AMD 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µs(2.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()
|
||||
}
|
||||
}
|
||||
267
internal/plugin/proc/capability.go
Normal file
267
internal/plugin/proc/capability.go
Normal 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.go):procCore 用命名字段持有内核 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(), ", "))
|
||||
}
|
||||
322
internal/plugin/proc/capability_test.go
Normal file
322
internal/plugin/proc/capability_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
733
internal/plugin/proc/corehandler.go
Normal file
733
internal/plugin/proc/corehandler.go
Normal 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.8):manifest 未声明的能力组被**明确拒绝**。
|
||||
// 不静默忽略: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
|
||||
}
|
||||
275
internal/plugin/proc/e2e_template_test.go
Normal file
275
internal/plugin/proc/e2e_template_test.go
Normal 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 —— 外部插件作者
|
||||
// 真正会拿到的那份运行时。它验证的是「模板 ↔ 内核」两侧协议/布局真的对齐,
|
||||
// 而不只是内核自己跟自己对齐。
|
||||
//
|
||||
// 插件业务代码只用公开 SDK(NewPluginFactory + 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.3:10 → 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 继承 fd,Windows 命名
|
||||
// 内核对象),故拆成带 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 改写未回传:实际 %q(C 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)
|
||||
}
|
||||
}
|
||||
81
internal/plugin/proc/evtfd_darwin.go
Normal file
81
internal/plugin/proc/evtfd_darwin.go
Normal file
@ -0,0 +1,81 @@
|
||||
//go:build darwin
|
||||
|
||||
package proc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// macOS 侧事件通知:用 pipe 模拟 eventfd(macOS 无 eventfd_create)。
|
||||
//
|
||||
// 与 eventfd 的语义差异:pipe 不具计数合并,多次写会触发多次读。
|
||||
// 这不影响正确性——消费者在 drainEvents 里按 readSeq 追 writeSeq 批量读,
|
||||
// 多次唤醒只多几次空循环(readSeq == writeSeq 时立即返回)。
|
||||
//
|
||||
// 走 Go netpoller:os.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()
|
||||
}
|
||||
}
|
||||
38
internal/plugin/proc/evtfd_linux.go
Normal file
38
internal/plugin/proc/evtfd_linux.go
Normal file
@ -0,0 +1,38 @@
|
||||
//go:build linux
|
||||
|
||||
package proc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// evtfdCreate 创建 Linux eventfd(EFD_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 约束 B:Bus.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) {}
|
||||
21
internal/plugin/proc/evtfd_other.go
Normal file
21
internal/plugin/proc/evtfd_other.go
Normal file
@ -0,0 +1,21 @@
|
||||
//go:build !linux && !darwin && !windows
|
||||
|
||||
package proc
|
||||
|
||||
// evtfdCreate:Windows 不支持 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) {}
|
||||
103
internal/plugin/proc/evtfd_windows.go
Normal file
103
internal/plugin/proc/evtfd_windows.go
Normal 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 路径省线程。每插件一个消费 goroutine,17 插件即 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 约束 B(Bus.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)
|
||||
}
|
||||
}
|
||||
277
internal/plugin/proc/evtring.go
Normal file
277
internal/plugin/proc/evtring.go
Normal 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 4);eventfd(fd 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)
|
||||
}
|
||||
}
|
||||
280
internal/plugin/proc/host.go
Normal file
280
internal/plugin/proc/host.go
Normal 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 拿到同一 memfd(fd 3),mmap 后即看到同一份物理页。
|
||||
//
|
||||
// 另外持有事件环段(§3.6):独立于 StageContext 的事件通知通道,
|
||||
// 子进程从 eventfd 感知新事件并从 mmap 读 slot。
|
||||
// fd 分配:fd 3 = StageContext,fd 4 = 事件环,fd 5 = eventfd。
|
||||
type Host struct {
|
||||
memfd *os.File
|
||||
data []byte
|
||||
seg *Segment
|
||||
shmSize int
|
||||
|
||||
// 事件环段(独立于 StageContext)
|
||||
evtfd *os.File // Unix:eventfd/pipe 读端(fd 5)。Windows 为 nil,用 evtNotifyFd 。
|
||||
evtNotifyFd int // 通知句柄的平台无关标识(Unix 是真 fd,Windows 是伪 fd)
|
||||
evtRing *EvtRing // 内核侧事件环句柄
|
||||
evtRingFd *os.File // Unix:事件环段 memfd(fd 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),但**布局**完全一致:
|
||||
// - Linux:memfd,经 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 段的大小。
|
||||
//
|
||||
// 取 256KB:StageContext 全字段 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 登记一个插件进入本次 stage;first 为真时把内核状态写入共享段。
|
||||
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.File(Unix;eventfd/pipe)。
|
||||
// Windows 返回 nil——命名 Event 不是文件句柄,用 EvtNotifyFd 代替。
|
||||
func (h *Host) Evtfd() *os.File { return h.evtfd }
|
||||
|
||||
// EvtNotifyFd 返回通知句柄的平台无关标识,供 EventRing 写通知。
|
||||
//
|
||||
// Unix 是真 fd;Windows 是映射到命名 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 }
|
||||
131
internal/plugin/proc/lock.go
Normal file
131
internal/plugin/proc/lock.go
Normal file
@ -0,0 +1,131 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 跨进程锁:锁仲裁回归内核(§3.7 已裁定,实验 3 + 实验 9 支撑)。
|
||||
//
|
||||
// 为什么不用 robust pthread_mutex:
|
||||
// - PTHREAD_PROCESS_SHARED + ROBUST 属性 Go 标准库无等价物,引入它意味着
|
||||
// **为了一把锁保留 cgo**——与"C 整体退场"的目标冲突。
|
||||
// - 锁仲裁回内核后,持锁进程崩溃由 cmd.Wait()/stdio EOF 检测,内核代为释放;
|
||||
// 实验 9 实测无死锁、**无需 EOWNERDEAD 处理**。
|
||||
// - 成本:一次 RPC 往返 19.4 µs(实验 3,20000 次测得)。stage handler 的加锁
|
||||
// 频率很低(每次 stage 一两次,不是每字段一次),微秒级往返可忽略。
|
||||
//
|
||||
// 于是整个新架构可做到**完全无 cgo**。
|
||||
|
||||
// lockWaitTimeout 是插件申请 stage 锁的最长等待时间。
|
||||
//
|
||||
// 取 30s:stage handler 自身受 60s 工具超时约束(toolcall.go),锁等待
|
||||
// 必须显著短于它,否则超时错误会指向错误的原因。超时返回错误而非
|
||||
// 静默继续——**持锁失败下改写共享段会破坏并发正确性**。
|
||||
const lockWaitTimeout = 30 * time.Second
|
||||
|
||||
// stageLock 是内核侧为单个 stage 执行持有的互斥体。
|
||||
//
|
||||
// 一次 RunStage 对应一个 stageLock 实例:同阶段并发扇出的所有插件
|
||||
// (含跨进程的)在此排队,语义等价于今日内置插件共享
|
||||
// *StageContext 的 sync.RWMutex——这正是"保留并发扇出原始设计"
|
||||
// (§0.2 第 1 条:并发扇出是原始设计,不是缺陷)。
|
||||
type stageLock struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// ownerMu 保护 owner/held,使 ForceRelease 能安全介入
|
||||
ownerMu sync.Mutex
|
||||
owner string // 当前持锁的插件名,空表示未持有
|
||||
held bool
|
||||
}
|
||||
|
||||
func newStageLock() *stageLock { return &stageLock{} }
|
||||
|
||||
// Acquire 为 plugin 申请写锁,带超时。
|
||||
//
|
||||
// 同一插件重复 Acquire 会死锁(stage handler 不应嵌套加锁),
|
||||
// 故显式拒绝并返回错误——比让插件挂死 30s 更容易排查。
|
||||
func (l *stageLock) Acquire(plugin string) error {
|
||||
l.ownerMu.Lock()
|
||||
if l.held && l.owner == plugin {
|
||||
l.ownerMu.Unlock()
|
||||
return fmt.Errorf("proc: 插件 %s 重复申请 stage 锁(handler 内不应嵌套加锁)", plugin)
|
||||
}
|
||||
l.ownerMu.Unlock()
|
||||
|
||||
acquired := make(chan struct{})
|
||||
go func() {
|
||||
l.mu.Lock()
|
||||
close(acquired)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-acquired:
|
||||
l.ownerMu.Lock()
|
||||
l.owner = plugin
|
||||
l.held = true
|
||||
l.ownerMu.Unlock()
|
||||
return nil
|
||||
case <-time.After(lockWaitTimeout):
|
||||
// 等待超时:上面的 goroutine 可能随后拿到锁,必须让它能释放,
|
||||
// 否则锁永久泄漏。用一个补偿 goroutine 等它拿到后立刻放掉。
|
||||
go func() {
|
||||
<-acquired
|
||||
l.ownerMu.Lock()
|
||||
stillFree := !l.held
|
||||
l.ownerMu.Unlock()
|
||||
if stillFree {
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
return fmt.Errorf("proc: 插件 %s 申请 stage 锁超时(%s)", plugin, lockWaitTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// Release 释放写锁。非持锁者调用返回错误(防止串扰)。
|
||||
func (l *stageLock) Release(plugin string) error {
|
||||
l.ownerMu.Lock()
|
||||
if !l.held {
|
||||
l.ownerMu.Unlock()
|
||||
return fmt.Errorf("proc: 插件 %s 释放未持有的 stage 锁", plugin)
|
||||
}
|
||||
if l.owner != plugin {
|
||||
owner := l.owner
|
||||
l.ownerMu.Unlock()
|
||||
return fmt.Errorf("proc: 插件 %s 试图释放 %s 持有的 stage 锁", plugin, owner)
|
||||
}
|
||||
l.owner = ""
|
||||
l.held = false
|
||||
l.ownerMu.Unlock()
|
||||
l.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForceRelease 在插件进程崩溃/退出时由内核代为释放其持有的锁(实验 9 的自愈机制)。
|
||||
//
|
||||
// 返回是否实际释放了锁。**这是"无需 robust mutex"的核心**:
|
||||
// 锁的所有权在内核进程,插件死亡由 cmd.Wait()/stdio EOF 检测到,
|
||||
// 内核直接解锁,不存在"持锁者死亡导致全局死锁"。
|
||||
func (l *stageLock) ForceRelease(plugin string) bool {
|
||||
l.ownerMu.Lock()
|
||||
if !l.held || l.owner != plugin {
|
||||
l.ownerMu.Unlock()
|
||||
return false
|
||||
}
|
||||
l.owner = ""
|
||||
l.held = false
|
||||
l.ownerMu.Unlock()
|
||||
l.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
// Owner 返回当前持锁插件名(诊断用)。
|
||||
func (l *stageLock) Owner() string {
|
||||
l.ownerMu.Lock()
|
||||
defer l.ownerMu.Unlock()
|
||||
if !l.held {
|
||||
return ""
|
||||
}
|
||||
return l.owner
|
||||
}
|
||||
184
internal/plugin/proc/lock_test.go
Normal file
184
internal/plugin/proc/lock_test.go
Normal file
@ -0,0 +1,184 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 锁仲裁回归内核(§3.7 已裁定)的行为验证,含实验 9 的崩溃自愈机制。
|
||||
|
||||
func TestStageLock_MutualExclusion(t *testing.T) {
|
||||
l := newStageLock()
|
||||
|
||||
if err := l.Acquire("A"); err != nil {
|
||||
t.Fatalf("A 应能获得锁: %v", err)
|
||||
}
|
||||
if l.Owner() != "A" {
|
||||
t.Errorf("Owner 应为 A,实际 %q", l.Owner())
|
||||
}
|
||||
|
||||
// B 在 A 持锁期间不得进入
|
||||
entered := make(chan struct{})
|
||||
go func() {
|
||||
_ = l.Acquire("B")
|
||||
close(entered)
|
||||
}()
|
||||
select {
|
||||
case <-entered:
|
||||
t.Fatal("A 持锁期间 B 不应获得锁")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
if err := l.Release("A"); err != nil {
|
||||
t.Fatalf("A 释放失败: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("A 释放后 B 应获得锁")
|
||||
}
|
||||
if l.Owner() != "B" {
|
||||
t.Errorf("Owner 应为 B,实际 %q", l.Owner())
|
||||
}
|
||||
_ = l.Release("B")
|
||||
}
|
||||
|
||||
// 非持锁者不得释放他人的锁(防止串扰导致并发正确性被破坏)。
|
||||
func TestStageLock_ReleaseByNonOwnerRejected(t *testing.T) {
|
||||
l := newStageLock()
|
||||
if err := l.Acquire("A"); err != nil {
|
||||
t.Fatalf("Acquire: %v", err)
|
||||
}
|
||||
defer l.Release("A")
|
||||
|
||||
err := l.Release("B")
|
||||
if err == nil {
|
||||
t.Fatal("非持锁者释放应被拒绝")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "试图释放") {
|
||||
t.Errorf("错误信息应说明串扰,实际: %v", err)
|
||||
}
|
||||
if l.Owner() != "A" {
|
||||
t.Errorf("A 应仍持锁,实际 owner=%q", l.Owner())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageLock_ReleaseWithoutHoldRejected(t *testing.T) {
|
||||
l := newStageLock()
|
||||
if err := l.Release("A"); err == nil {
|
||||
t.Fatal("未持锁时释放应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// handler 内嵌套加锁会死锁,应显式拒绝而不是让插件挂死到超时。
|
||||
func TestStageLock_ReentrantAcquireRejected(t *testing.T) {
|
||||
l := newStageLock()
|
||||
if err := l.Acquire("A"); err != nil {
|
||||
t.Fatalf("Acquire: %v", err)
|
||||
}
|
||||
defer l.Release("A")
|
||||
|
||||
err := l.Acquire("A")
|
||||
if err == nil {
|
||||
t.Fatal("同一插件重复加锁应被拒绝(否则死锁 30s)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "重复申请") {
|
||||
t.Errorf("错误信息应说明重复加锁,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 实验 9 的核心:持锁进程崩溃后内核代为释放,后续插件不死锁。
|
||||
// 这条彻底排除了 robust pthread_mutex 的必要性 —— 整个架构零 cgo。
|
||||
func TestStageLock_ForceReleaseOnPluginCrash(t *testing.T) {
|
||||
l := newStageLock()
|
||||
|
||||
// 插件 X 拿锁后"崩溃"(不调用 Release)
|
||||
if err := l.Acquire("X"); err != nil {
|
||||
t.Fatalf("X Acquire: %v", err)
|
||||
}
|
||||
if !l.ForceRelease("X") {
|
||||
t.Fatal("内核应能强制释放崩溃插件持有的锁")
|
||||
}
|
||||
if l.Owner() != "" {
|
||||
t.Errorf("强制释放后应无持有者,实际 %q", l.Owner())
|
||||
}
|
||||
|
||||
// 插件 Y 随后必须能正常拿到锁(无死锁)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- l.Acquire("Y") }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("Y 应能获得锁: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("X 崩溃后 Y 无法获得锁 —— 出现死锁")
|
||||
}
|
||||
if err := l.Release("Y"); err != nil {
|
||||
t.Fatalf("Y 释放失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ForceRelease 对非持有者/未持锁应为 no-op,不能误放他人的锁。
|
||||
func TestStageLock_ForceReleaseIsTargeted(t *testing.T) {
|
||||
l := newStageLock()
|
||||
if err := l.Acquire("A"); err != nil {
|
||||
t.Fatalf("Acquire: %v", err)
|
||||
}
|
||||
defer l.Release("A")
|
||||
|
||||
if l.ForceRelease("B") {
|
||||
t.Error("强制释放不该动 A 持有的锁")
|
||||
}
|
||||
if l.Owner() != "A" {
|
||||
t.Errorf("A 应仍持锁,实际 %q", l.Owner())
|
||||
}
|
||||
}
|
||||
|
||||
// 高并发下锁的串行化保证:临界区不重叠。
|
||||
func TestStageLock_SerializesCriticalSection(t *testing.T) {
|
||||
l := newStageLock()
|
||||
var (
|
||||
mu sync.Mutex
|
||||
inside int
|
||||
maxSeen int
|
||||
)
|
||||
const workers = 8
|
||||
const iters = 50
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
name := string(rune('A' + id))
|
||||
for j := 0; j < iters; j++ {
|
||||
if err := l.Acquire(name); err != nil {
|
||||
t.Errorf("Acquire: %v", err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
inside++
|
||||
if inside > maxSeen {
|
||||
maxSeen = inside
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
mu.Lock()
|
||||
inside--
|
||||
mu.Unlock()
|
||||
if err := l.Release(name); err != nil {
|
||||
t.Errorf("Release: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if maxSeen > 1 {
|
||||
t.Fatalf("临界区出现并发:同时 %d 个持有者", maxSeen)
|
||||
}
|
||||
}
|
||||
283
internal/plugin/proc/plugin.go
Normal file
283
internal/plugin/proc/plugin.go
Normal 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
|
||||
393
internal/plugin/proc/plugin_test.go
Normal file
393
internal/plugin/proc/plugin_test.go
Normal 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("发送失败时必须上报 error(C 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.8):CoreSDK 不提供内核内部机制,
|
||||
// 插件请求这些能力时必须被拒绝而非静默忽略。
|
||||
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,全部标记必须保留。
|
||||
//
|
||||
// 这是实验 8(5 进程 × 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 != 最终长度 %d(final=%q counts=%v)",
|
||||
total, len(sc.FinalText), sc.FinalText, counts)
|
||||
}
|
||||
if total != len(tags) {
|
||||
t.Fatalf("出现 lost update:期望 %d 个插件的写入全部保留,实际 %d(final=%q counts=%v)",
|
||||
len(tags), total, sc.FinalText, counts)
|
||||
}
|
||||
for tag, c := range counts {
|
||||
if c != 1 {
|
||||
t.Errorf("插件 %s 的写入丢失:期望 1 次,实际 %d 次", tag, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
30
internal/plugin/proc/procattr_linux.go
Normal file
30
internal/plugin/proc/procattr_linux.go
Normal 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
|
||||
}
|
||||
15
internal/plugin/proc/procattr_other.go
Normal file
15
internal/plugin/proc/procattr_other.go
Normal file
@ -0,0 +1,15 @@
|
||||
//go:build !linux
|
||||
|
||||
package proc
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// applyProcAttr 在非 Linux 平台是空实现。
|
||||
//
|
||||
// macOS 没有 Pdeathsig(kqueue 的 NOTE_EXIT 要求父进程存活才能监听,
|
||||
// 恰好在父进程被 SIGKILL 时失效);Windows 的 Job Object 可做到类似效果,
|
||||
// 但需要额外的句柄管理,且 Windows 侧尚未真机验证(§12.5),不在此引入。
|
||||
//
|
||||
// 后果:这两个平台上 homed 被强杀时子进程会成为孤儿。
|
||||
// 正常关停路径(Supervisor.StopAll)不受影响。
|
||||
func applyProcAttr(cmd *exec.Cmd) {}
|
||||
598
internal/plugin/proc/process.go
Normal file
598
internal/plugin/proc/process.go
Normal 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 的 EOF:EOF 只说明 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 != "" {
|
||||
// 插件发起的调用:拷贝一份再交给 goroutine(scanner 会复用底层数组)
|
||||
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()——它属于 waitLoop,Wait 并非并发安全,
|
||||
// 两处调会报 "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 约束 B:post-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=0,fire-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
|
||||
}
|
||||
481
internal/plugin/proc/process_test.go
Normal file
481
internal/plugin/proc/process_test.go
Normal 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("关停后冒出的进程应被立即结束")
|
||||
}
|
||||
}
|
||||
215
internal/plugin/proc/protocol.go
Normal file
215
internal/plugin/proc/protocol.go
Normal 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 实验 10:18-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 → kernel(51 个 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 25(CORE_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
307
internal/plugin/proc/shm.go
Normal file
@ -0,0 +1,307 @@
|
||||
// Package proc 实现外部插件的子进程加载通道(plugin.bin)。
|
||||
//
|
||||
// 设计依据:docs/zh/架构迁移评估.md 第三章
|
||||
//
|
||||
// homed ──spawn──> plugin(纯 Go 二进制,无 cgo)
|
||||
// ├── stdio JSON-RPC 控制面:51 个 method id 平移为 method 名(§3.2)
|
||||
// ├── shm + 偏移 数据面:StageContext 并发改写、二进制零拷贝(§3.3)
|
||||
// └── eventfd 通知面:事件环 post-and-forget(§3.6)
|
||||
//
|
||||
// 本文件负责数据面的共享段布局与 arena 分配器。
|
||||
package proc
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// 共享段魔数与版本,用于挂载时校验对端布局一致。
|
||||
const (
|
||||
shmMagic uint32 = 0x48415348 // "HASH" — HomeAgent SHared
|
||||
shmVersion uint32 = 1
|
||||
)
|
||||
|
||||
// 段布局(所有偏移均相对**段起始**,arena 内偏移相对 arenaBase):
|
||||
//
|
||||
// [0, headerSize) Header:魔数/版本/arena 游标
|
||||
// [headerSize, ctxEnd) ShmStageCtx:每字段一个 Slice{off,len} 描述符
|
||||
// [arenaBase, arenaBase+arenaCap) arena:append-only 变长数据区
|
||||
//
|
||||
// **相对偏移是关键**(§3.3)——各进程 mmap 到不同虚拟地址仍能正确解引用。
|
||||
const (
|
||||
headerSize = 64
|
||||
|
||||
// Header 内字段偏移
|
||||
offMagic = 0 // uint32
|
||||
offVersion = 4 // uint32
|
||||
offArenaBase = 8 // uint32
|
||||
offArenaCap = 12 // uint32
|
||||
offArenaUsed = 16 // uint32(原子 bump 游标)
|
||||
offCtxBase = 20 // uint32
|
||||
offSeq = 24 // uint64:每次成功写回自增,供乐观读校验
|
||||
)
|
||||
|
||||
// Slice 是 arena 内变长数据的描述符,off 相对 arenaBase。
|
||||
// 长度为 0 表示空值;off==0 && len==0 表示"字段未设置"。
|
||||
type Slice struct {
|
||||
Off uint32
|
||||
Len uint32
|
||||
}
|
||||
|
||||
const sliceSize = 8
|
||||
|
||||
// IsUnset 报告该描述符是否表示"字段从未被写入"。
|
||||
// 注意与"写入了空字符串"区分:后者 Off 非 0、Len 为 0。
|
||||
func (s Slice) IsUnset() bool { return s.Off == 0 && s.Len == 0 }
|
||||
|
||||
// stageField 枚举 StageContext 的 16 个字段在共享段中的槽位。
|
||||
//
|
||||
// **字段级粒度是消除 lost update 的机制**:每个字段独立一个 Slice 描述符,
|
||||
// 只改 FinalText 的插件完全不触碰 ToolResults 的描述符,因此不存在
|
||||
// "只读插件把自己收到的旧快照写回、覆盖他人改写"的问题(对比今日副本模型
|
||||
// 实测 35.8~36.8% 丢失率,见 §8.4)。
|
||||
//
|
||||
// 字段内部的编码方式(原始字符串 vs JSON)不影响这一性质:
|
||||
// ToolCall.Arguments 是 map[string]interface{}、ToolResult.Result 是 interface{},
|
||||
// 无法拆成定长结构,故以 JSON 存入 arena——工具结果中位数仅 93B(§2.5),
|
||||
// 序列化开销占 LLM 往返的 0.0001%,不构成瓶颈。
|
||||
type stageField int
|
||||
|
||||
const (
|
||||
fRawMessage stageField = iota
|
||||
fUserID
|
||||
fGroupID
|
||||
fLLMText
|
||||
fReasoningContent
|
||||
fFinalText
|
||||
fResponse // 配合 fResponseSet 表达 *string 的 nil 语义
|
||||
fPhase
|
||||
fContextMsgs // JSON
|
||||
fToolCalls // JSON
|
||||
fToolResults // JSON
|
||||
fMemory // JSON
|
||||
fTokenUsage // JSON
|
||||
fErrors // JSON
|
||||
fExtraMediaBlocks // JSON —— Extra 的 4 个键提升为具名字段(§3.3 已核实使用点)
|
||||
fExtraMediaType
|
||||
fExtraInputSource
|
||||
fExtraOutputChannel
|
||||
|
||||
stageFieldCount
|
||||
)
|
||||
|
||||
// 标志位区(紧跟描述符数组):表达 bool 与指针的 nil 语义。
|
||||
const (
|
||||
flagNoMemory = 0
|
||||
flagResponseSet = 1
|
||||
flagCount = 8 // 预留到 8 字节,便于对齐与后续扩展
|
||||
)
|
||||
|
||||
// ctxSize 是 ShmStageCtx 区域的总字节数。
|
||||
const ctxSize = int(stageFieldCount)*sliceSize + flagCount
|
||||
|
||||
// Segment 是一块已 mmap 的共享段,内核与插件进程各持一个实例
|
||||
// (底层同一物理页,虚拟地址可不同)。
|
||||
type Segment struct {
|
||||
data []byte // 完整 mmap 区域
|
||||
}
|
||||
|
||||
// NewSegment 在给定的 mmap 区域上初始化段布局(内核侧调用一次)。
|
||||
func NewSegment(data []byte) (*Segment, error) {
|
||||
if len(data) < headerSize+ctxSize+1 {
|
||||
return nil, fmt.Errorf("proc: 共享段过小(%d 字节,至少需要 %d)",
|
||||
len(data), headerSize+ctxSize+1)
|
||||
}
|
||||
s := &Segment{data: data}
|
||||
|
||||
arenaBase := uint32(headerSize + ctxSize)
|
||||
arenaCap := uint32(len(data)) - arenaBase
|
||||
|
||||
binary.LittleEndian.PutUint32(data[offMagic:], shmMagic)
|
||||
binary.LittleEndian.PutUint32(data[offVersion:], shmVersion)
|
||||
binary.LittleEndian.PutUint32(data[offArenaBase:], arenaBase)
|
||||
binary.LittleEndian.PutUint32(data[offArenaCap:], arenaCap)
|
||||
binary.LittleEndian.PutUint32(data[offArenaUsed:], 0)
|
||||
binary.LittleEndian.PutUint32(data[offCtxBase:], headerSize)
|
||||
binary.LittleEndian.PutUint64(data[offSeq:], 0)
|
||||
|
||||
// 描述符与标志位清零(IsUnset 语义依赖此)
|
||||
for i := headerSize; i < headerSize+ctxSize; i++ {
|
||||
data[i] = 0
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// AttachSegment 挂载一块已由 NewSegment 初始化的区域(插件进程侧调用)。
|
||||
// 校验魔数与版本,避免版本不一致时静默错读。
|
||||
func AttachSegment(data []byte) (*Segment, error) {
|
||||
if len(data) < headerSize+ctxSize {
|
||||
return nil, fmt.Errorf("proc: 共享段过小(%d 字节)", len(data))
|
||||
}
|
||||
if got := binary.LittleEndian.Uint32(data[offMagic:]); got != shmMagic {
|
||||
return nil, fmt.Errorf("proc: 共享段魔数不匹配(0x%x,期望 0x%x)", got, shmMagic)
|
||||
}
|
||||
if got := binary.LittleEndian.Uint32(data[offVersion:]); got != shmVersion {
|
||||
return nil, fmt.Errorf("proc: 共享段版本不匹配(%d,本内核 %d)——插件需用配套 plugindev 重编",
|
||||
got, shmVersion)
|
||||
}
|
||||
return &Segment{data: data}, nil
|
||||
}
|
||||
|
||||
func (s *Segment) arenaBase() uint32 { return binary.LittleEndian.Uint32(s.data[offArenaBase:]) }
|
||||
func (s *Segment) arenaCap() uint32 { return binary.LittleEndian.Uint32(s.data[offArenaCap:]) }
|
||||
func (s *Segment) ctxBase() uint32 { return binary.LittleEndian.Uint32(s.data[offCtxBase:]) }
|
||||
|
||||
// Seq 返回当前世代号。每次 WriteBack 成功后自增,供乐观读校验(§3.3)。
|
||||
func (s *Segment) Seq() uint64 {
|
||||
return atomic.LoadUint64((*uint64)(ptrU64(s.data[offSeq:])))
|
||||
}
|
||||
|
||||
func (s *Segment) bumpSeq() { atomic.AddUint64((*uint64)(ptrU64(s.data[offSeq:])), 1) }
|
||||
|
||||
// ArenaUsed 返回 arena 已用字节数(诊断/压实判断用)。
|
||||
func (s *Segment) ArenaUsed() uint32 {
|
||||
return atomic.LoadUint32((*uint32)(ptrU32(s.data[offArenaUsed:])))
|
||||
}
|
||||
|
||||
// ArenaCap 返回 arena 容量。
|
||||
func (s *Segment) ArenaCap() uint32 { return s.arenaCap() }
|
||||
|
||||
// alloc 在 arena 上分配 n 字节并返回相对 arenaBase 的偏移。
|
||||
//
|
||||
// **append-only(§3.3)**:插件把 FinalText 从 10 字节改成 10KB 时分配新区域、
|
||||
// 更新描述符,旧区域留作垃圾;arena 用尽由内核在 stage 结束后(此时无插件持锁)
|
||||
// 整体压实。代价是单次 stage 内写入总量有上限——**上限必须显式报错而非静默截断**
|
||||
// (§4.4 风险登记)。
|
||||
//
|
||||
// 调用方须持有 stage 写锁(锁仲裁见 lock.go),故这里用非原子的读-改-写即可;
|
||||
// 仍用原子操作是为了让未持锁的诊断读取(ArenaUsed)不产生数据竞争。
|
||||
func (s *Segment) alloc(n int) (uint32, error) {
|
||||
if n < 0 {
|
||||
return 0, fmt.Errorf("proc: 非法分配长度 %d", n)
|
||||
}
|
||||
// 偏移 0 保留给"字段未设置"语义,故 arena 从 1 开始分配。
|
||||
used := s.ArenaUsed()
|
||||
if used == 0 {
|
||||
used = 1
|
||||
}
|
||||
end := uint64(used) + uint64(n)
|
||||
if end > uint64(s.arenaCap()) {
|
||||
return 0, fmt.Errorf("proc: arena 空间不足——需要 %d 字节,剩余 %d 字节(容量 %d,已用 %d);"+
|
||||
"单次 stage 写入总量超限,请减少写入或等待内核压实",
|
||||
n, int64(s.arenaCap())-int64(used), s.arenaCap(), used)
|
||||
}
|
||||
atomic.StoreUint32((*uint32)(ptrU32(s.data[offArenaUsed:])), uint32(end))
|
||||
return used, nil
|
||||
}
|
||||
|
||||
// write 把 b 写入 arena 并返回描述符。空切片返回 {Off:1, Len:0}
|
||||
// (非 IsUnset —— 表达"写入了空值",与"未设置"区分)。
|
||||
func (s *Segment) write(b []byte) (Slice, error) {
|
||||
if len(b) == 0 {
|
||||
return Slice{Off: 1, Len: 0}, nil
|
||||
}
|
||||
off, err := s.alloc(len(b))
|
||||
if err != nil {
|
||||
return Slice{}, err
|
||||
}
|
||||
base := s.arenaBase()
|
||||
copy(s.data[base+off:base+off+uint32(len(b))], b)
|
||||
return Slice{Off: off, Len: uint32(len(b))}, nil
|
||||
}
|
||||
|
||||
// read 按描述符取出 arena 中的字节(返回的是段内切片视图,调用方须在持锁期间使用)。
|
||||
func (s *Segment) read(sl Slice) ([]byte, error) {
|
||||
if sl.IsUnset() || sl.Len == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
base := s.arenaBase()
|
||||
if uint64(sl.Off)+uint64(sl.Len) > uint64(s.arenaCap()) {
|
||||
return nil, fmt.Errorf("proc: 描述符越界(off=%d len=%d cap=%d)", sl.Off, sl.Len, s.arenaCap())
|
||||
}
|
||||
return s.data[base+sl.Off : base+sl.Off+sl.Len], nil
|
||||
}
|
||||
|
||||
// descOffset 返回字段 f 的描述符在段内的绝对偏移。
|
||||
func (s *Segment) descOffset(f stageField) uint32 {
|
||||
return s.ctxBase() + uint32(int(f)*sliceSize)
|
||||
}
|
||||
|
||||
func (s *Segment) getDesc(f stageField) Slice {
|
||||
o := s.descOffset(f)
|
||||
return Slice{
|
||||
Off: binary.LittleEndian.Uint32(s.data[o:]),
|
||||
Len: binary.LittleEndian.Uint32(s.data[o+4:]),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Segment) setDesc(f stageField, sl Slice) {
|
||||
o := s.descOffset(f)
|
||||
binary.LittleEndian.PutUint32(s.data[o:], sl.Off)
|
||||
binary.LittleEndian.PutUint32(s.data[o+4:], sl.Len)
|
||||
}
|
||||
|
||||
func (s *Segment) flagsOffset() uint32 {
|
||||
return s.ctxBase() + uint32(int(stageFieldCount)*sliceSize)
|
||||
}
|
||||
|
||||
func (s *Segment) getFlag(bit int) bool {
|
||||
return s.data[s.flagsOffset()+uint32(bit)] != 0
|
||||
}
|
||||
|
||||
func (s *Segment) setFlag(bit int, v bool) {
|
||||
b := byte(0)
|
||||
if v {
|
||||
b = 1
|
||||
}
|
||||
s.data[s.flagsOffset()+uint32(bit)] = b
|
||||
}
|
||||
|
||||
// Compact 回收 arena 垃圾:把仍被描述符引用的数据紧凑重排到段头部。
|
||||
//
|
||||
// 必须在**无插件持锁**时调用(§3.3:由内核在 stage 结束后执行)。
|
||||
// 返回回收的字节数。
|
||||
func (s *Segment) Compact() uint32 {
|
||||
before := s.ArenaUsed()
|
||||
|
||||
// 收集现存描述符指向的数据,按字段顺序重新写入。
|
||||
type kept struct {
|
||||
f stageField
|
||||
data []byte
|
||||
}
|
||||
var live []kept
|
||||
for f := stageField(0); f < stageFieldCount; f++ {
|
||||
sl := s.getDesc(f)
|
||||
if sl.IsUnset() {
|
||||
continue
|
||||
}
|
||||
b, err := s.read(sl)
|
||||
if err != nil {
|
||||
// 描述符损坏:丢弃该字段而非让压实失败(诊断由上层日志承担)
|
||||
s.setDesc(f, Slice{})
|
||||
continue
|
||||
}
|
||||
cp := make([]byte, len(b))
|
||||
copy(cp, b)
|
||||
live = append(live, kept{f: f, data: cp})
|
||||
}
|
||||
|
||||
// 重置游标后按序回填
|
||||
atomic.StoreUint32((*uint32)(ptrU32(s.data[offArenaUsed:])), 0)
|
||||
for _, k := range live {
|
||||
sl, err := s.write(k.data)
|
||||
if err != nil {
|
||||
// 压实后仍放不下:理论上不可能(总量未增),保守清空该字段
|
||||
s.setDesc(k.f, Slice{})
|
||||
continue
|
||||
}
|
||||
s.setDesc(k.f, sl)
|
||||
}
|
||||
|
||||
after := s.ArenaUsed()
|
||||
if before > after {
|
||||
return before - after
|
||||
}
|
||||
return 0
|
||||
}
|
||||
441
internal/plugin/proc/shm_test.go
Normal file
441
internal/plugin/proc/shm_test.go
Normal file
@ -0,0 +1,441 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// 本文件验证共享内存 stage 并发的正确性——**整个迁移最关键的一环**(§4.4 风险 3.4)。
|
||||
//
|
||||
// 对照基线(今日 C ABI 副本模型):
|
||||
// - 内置插件(共享 *StageContext + RWMutex):0% 丢失
|
||||
// - 外部插件(快照-副本-写回):35.8~36.8% 丢失(实验 12),现网量级百分之几脏数据
|
||||
//
|
||||
// 目标:共享内存 + 锁仲裁下,跨进程并发改写收敛到 0% 丢失。
|
||||
|
||||
// newTestSegment 造一块内存段模拟 mmap 区域(单测无需真 mmap:
|
||||
// 编解码与 arena 逻辑与底层是 mmap 还是普通内存无关)。
|
||||
func newTestSegment(t *testing.T, size int) *Segment {
|
||||
t.Helper()
|
||||
buf := make([]byte, size)
|
||||
seg, err := NewSegment(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSegment: %v", err)
|
||||
}
|
||||
return seg
|
||||
}
|
||||
|
||||
func TestSegment_AttachValidatesMagicAndVersion(t *testing.T) {
|
||||
buf := make([]byte, 8192)
|
||||
if _, err := NewSegment(buf); err != nil {
|
||||
t.Fatalf("NewSegment: %v", err)
|
||||
}
|
||||
if _, err := AttachSegment(buf); err != nil {
|
||||
t.Fatalf("AttachSegment 应成功: %v", err)
|
||||
}
|
||||
|
||||
// 魔数损坏
|
||||
bad := make([]byte, len(buf))
|
||||
copy(bad, buf)
|
||||
bad[0] ^= 0xFF
|
||||
if _, err := AttachSegment(bad); err == nil {
|
||||
t.Error("魔数不匹配应报错(避免版本不一致时静默错读)")
|
||||
}
|
||||
|
||||
// 版本不匹配
|
||||
badVer := make([]byte, len(buf))
|
||||
copy(badVer, buf)
|
||||
badVer[4] = 99
|
||||
if _, err := AttachSegment(badVer); err == nil {
|
||||
t.Error("版本不匹配应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// 全部 16 个字段可跨进程往返——今日经 C ABI 只有 10 个字段可见(§8.3)。
|
||||
func TestSegment_RoundTripAllFields(t *testing.T) {
|
||||
seg := newTestSegment(t, 16384)
|
||||
|
||||
resp := "短路响应"
|
||||
src := &pubsdk.StageContext{
|
||||
RawMessage: "原始输入",
|
||||
UserID: "u1",
|
||||
GroupID: "g1",
|
||||
LLMText: "模型输出",
|
||||
ReasoningContent: "思考过程", // C ABI 下外部插件看不到
|
||||
FinalText: "最终文本",
|
||||
Response: &resp,
|
||||
Phase: pubsdk.StageAfterToolcall,
|
||||
NoMemory: true,
|
||||
ContextMsgs: []map[string]interface{}{{"role": "user", "content": "hi"}}, // C ABI 看不到
|
||||
ToolCalls: []pubsdk.ToolCall{{ID: "t1", Name: "weather_query", Plugin: "weather"}},
|
||||
ToolResults: []pubsdk.ToolResult{{CallID: "t1", Name: "weather_query", Success: true, Result: "晴"}},
|
||||
Memory: []pubsdk.MemItem{{Role: "user", Content: "记忆", Score: 0.9}}, // C ABI 看不到
|
||||
TokenUsage: map[string]int{"prompt": 100, "completion": 50}, // C ABI 看不到
|
||||
Errors: []string{"err1"}, // C ABI 看不到
|
||||
Extra: map[string]interface{}{
|
||||
ExtraKeyMediaType: "image",
|
||||
ExtraKeyInputSource: "qq",
|
||||
ExtraKeyOutputChannel: "qq",
|
||||
},
|
||||
}
|
||||
|
||||
if err := seg.WriteAll(src); err != nil {
|
||||
t.Fatalf("WriteAll: %v", err)
|
||||
}
|
||||
|
||||
var dst pubsdk.StageContext
|
||||
if err := seg.ReadInto(&dst); err != nil {
|
||||
t.Fatalf("ReadInto: %v", err)
|
||||
}
|
||||
|
||||
if dst.RawMessage != src.RawMessage || dst.UserID != src.UserID || dst.GroupID != src.GroupID {
|
||||
t.Errorf("标量字段不一致: raw=%q uid=%q gid=%q", dst.RawMessage, dst.UserID, dst.GroupID)
|
||||
}
|
||||
if dst.ReasoningContent != "思考过程" {
|
||||
t.Errorf("ReasoningContent 应可见(C ABI 下不可见): %q", dst.ReasoningContent)
|
||||
}
|
||||
if len(dst.ContextMsgs) != 1 {
|
||||
t.Errorf("ContextMsgs 应可见: %v", dst.ContextMsgs)
|
||||
}
|
||||
if len(dst.Memory) != 1 || dst.Memory[0].Score != 0.9 {
|
||||
t.Errorf("Memory 应可见: %v", dst.Memory)
|
||||
}
|
||||
if dst.TokenUsage["prompt"] != 100 {
|
||||
t.Errorf("TokenUsage 应可见: %v", dst.TokenUsage)
|
||||
}
|
||||
if len(dst.Errors) != 1 {
|
||||
t.Errorf("Errors 应可见: %v", dst.Errors)
|
||||
}
|
||||
if dst.Response == nil || *dst.Response != resp {
|
||||
t.Errorf("Response 应往返: %v", dst.Response)
|
||||
}
|
||||
if !dst.NoMemory {
|
||||
t.Error("NoMemory 标志应往返")
|
||||
}
|
||||
if len(dst.ToolResults) != 1 || dst.ToolResults[0].Result != "晴" {
|
||||
t.Errorf("ToolResults 应往返: %v", dst.ToolResults)
|
||||
}
|
||||
if dst.Extra[ExtraKeyMediaType] != "image" {
|
||||
t.Errorf("Extra 提升字段应往返: %v", dst.Extra)
|
||||
}
|
||||
}
|
||||
|
||||
// nil Response 与空字符串 Response 必须可区分(短路语义依赖此)。
|
||||
func TestSegment_ResponseNilVsEmpty(t *testing.T) {
|
||||
seg := newTestSegment(t, 8192)
|
||||
|
||||
if err := seg.WriteAll(&pubsdk.StageContext{RawMessage: "x"}); err != nil {
|
||||
t.Fatalf("WriteAll: %v", err)
|
||||
}
|
||||
var d1 pubsdk.StageContext
|
||||
if err := seg.ReadInto(&d1); err != nil {
|
||||
t.Fatalf("ReadInto: %v", err)
|
||||
}
|
||||
if d1.Response != nil {
|
||||
t.Errorf("未设置的 Response 应为 nil,实际 %q", *d1.Response)
|
||||
}
|
||||
|
||||
empty := ""
|
||||
seg2 := newTestSegment(t, 8192)
|
||||
if err := seg2.WriteAll(&pubsdk.StageContext{Response: &empty}); err != nil {
|
||||
t.Fatalf("WriteAll: %v", err)
|
||||
}
|
||||
var d2 pubsdk.StageContext
|
||||
if err := seg2.ReadInto(&d2); err != nil {
|
||||
t.Fatalf("ReadInto: %v", err)
|
||||
}
|
||||
if d2.Response == nil {
|
||||
t.Error("显式设为空串的 Response 不应读成 nil(短路语义会丢)")
|
||||
} else if *d2.Response != "" {
|
||||
t.Errorf("Response 应为空串,实际 %q", *d2.Response)
|
||||
}
|
||||
}
|
||||
|
||||
// 只读插件的 WriteDirty 必须零写入——**这是消除 lost update 的核心断言**。
|
||||
func TestSegment_WriteDirty_ReadOnlyPluginWritesNothing(t *testing.T) {
|
||||
seg := newTestSegment(t, 16384)
|
||||
base := &pubsdk.StageContext{
|
||||
RawMessage: "查天气",
|
||||
ToolResults: []pubsdk.ToolResult{{CallID: "c1", Name: "weather_query", Result: "已清洗"}},
|
||||
}
|
||||
if err := seg.WriteAll(base); err != nil {
|
||||
t.Fatalf("WriteAll: %v", err)
|
||||
}
|
||||
|
||||
// 插件侧:读入 → 只读 → 写回
|
||||
var local pubsdk.StageContext
|
||||
if err := seg.ReadInto(&local); err != nil {
|
||||
t.Fatalf("ReadInto: %v", err)
|
||||
}
|
||||
snap := TakeSnapshot(&local)
|
||||
_ = local.ToolResults[0].Result // 只读,不改
|
||||
|
||||
n, err := seg.WriteDirty(&local, snap)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteDirty: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("只读插件应零写回,实际写回 %d 个字段(会覆盖他人改写)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 原地改切片元素必须被识别为脏 —— C ABI 侧修 11.3 时踩过的坑。
|
||||
func TestSegment_WriteDirty_InPlaceSliceMutationDetected(t *testing.T) {
|
||||
seg := newTestSegment(t, 16384)
|
||||
if err := seg.WriteAll(&pubsdk.StageContext{
|
||||
ToolResults: []pubsdk.ToolResult{{CallID: "c1", Result: "带\x1b[31mANSI\x1b[0m"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteAll: %v", err)
|
||||
}
|
||||
|
||||
var local pubsdk.StageContext
|
||||
if err := seg.ReadInto(&local); err != nil {
|
||||
t.Fatalf("ReadInto: %v", err)
|
||||
}
|
||||
snap := TakeSnapshot(&local)
|
||||
local.ToolResults[0].Result = "带ANSI" // 原地改元素(sanitizer 的实际行为)
|
||||
|
||||
n, err := seg.WriteDirty(&local, snap)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteDirty: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("原地改切片元素应被识别为 1 个脏字段,实际 %d", n)
|
||||
}
|
||||
|
||||
var after pubsdk.StageContext
|
||||
if err := seg.ReadInto(&after); err != nil {
|
||||
t.Fatalf("ReadInto: %v", err)
|
||||
}
|
||||
if after.ToolResults[0].Result != "带ANSI" {
|
||||
t.Errorf("清洗结果未写回: %v", after.ToolResults[0].Result)
|
||||
}
|
||||
}
|
||||
|
||||
// 复刻现网场景(实验 13):sanitizer 改写 + weather 只读并发,清洗结果不得被覆盖。
|
||||
// 这是 C ABI 副本模型下量级百分之几脏数据的直接来源。
|
||||
func TestSegment_ProductionScenario_SanitizerNotOverwrittenByWeather(t *testing.T) {
|
||||
seg := newTestSegment(t, 16384)
|
||||
dirty := "天气:晴 \x1b[31m28°C\x1b[0m"
|
||||
clean := "天气:晴 28°C"
|
||||
|
||||
if err := seg.WriteAll(&pubsdk.StageContext{
|
||||
RawMessage: "查天气",
|
||||
Phase: pubsdk.StageAfterToolcall,
|
||||
ToolResults: []pubsdk.ToolResult{{CallID: "c1", Name: "weather_query", Result: dirty}},
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteAll: %v", err)
|
||||
}
|
||||
|
||||
lock := newStageLock()
|
||||
|
||||
// sanitizer:拿锁 → 读 → 清洗 → 写脏字段 → 放锁
|
||||
runSanitizer := func() error {
|
||||
if err := lock.Acquire("sanitizer"); err != nil {
|
||||
return err
|
||||
}
|
||||
defer lock.Release("sanitizer")
|
||||
var local pubsdk.StageContext
|
||||
if err := seg.ReadInto(&local); err != nil {
|
||||
return err
|
||||
}
|
||||
snap := TakeSnapshot(&local)
|
||||
if len(local.ToolResults) > 0 {
|
||||
s, _ := local.ToolResults[0].Result.(string)
|
||||
local.ToolResults[0].Result = strings.NewReplacer("\x1b[31m", "", "\x1b[0m", "").Replace(s)
|
||||
}
|
||||
_, err := seg.WriteDirty(&local, snap)
|
||||
return err
|
||||
}
|
||||
|
||||
// weather:拿锁 → 读 → 只读 → 零写回 → 放锁
|
||||
runWeather := func() error {
|
||||
if err := lock.Acquire("weather"); err != nil {
|
||||
return err
|
||||
}
|
||||
defer lock.Release("weather")
|
||||
var local pubsdk.StageContext
|
||||
if err := seg.ReadInto(&local); err != nil {
|
||||
return err
|
||||
}
|
||||
snap := TakeSnapshot(&local)
|
||||
if len(local.ToolResults) > 0 {
|
||||
_ = local.ToolResults[0].Result // 只读
|
||||
}
|
||||
n, err := seg.WriteDirty(&local, snap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != 0 {
|
||||
return fmt.Errorf("weather 只读却写回 %d 个字段", n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 并发扇出(保留原始设计),weather 后完成是最坏情形
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); errs <- runSanitizer() }()
|
||||
go func() { defer wg.Done(); errs <- runWeather() }()
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("插件执行失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var final pubsdk.StageContext
|
||||
if err := seg.ReadInto(&final); err != nil {
|
||||
t.Fatalf("ReadInto: %v", err)
|
||||
}
|
||||
got, _ := final.ToolResults[0].Result.(string)
|
||||
if got != clean {
|
||||
t.Fatalf("清洗结果被覆盖:期望 %q,实际 %q", clean, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 多插件高并发累加同一字段:总写入次数必须等于最终长度(零丢失零撕裂)。
|
||||
// 对应实验 8(5 进程 × 300 轮),这里在单进程内用 goroutine 模拟并发扇出,
|
||||
// 验证共享段 + 锁仲裁 + 脏字段写回三者组合的正确性。
|
||||
func TestSegment_ConcurrentAppend_NoLostUpdate(t *testing.T) {
|
||||
// arena 需容纳 append-only 的中间垃圾:每轮写入长度递增,
|
||||
// 5 插件 × 40 轮 → 最长 200 字符,累计约 200*201/2 = 20100 字节,留足余量。
|
||||
seg := newTestSegment(t, 128*1024)
|
||||
if err := seg.WriteAll(&pubsdk.StageContext{FinalText: ""}); err != nil {
|
||||
t.Fatalf("WriteAll: %v", err)
|
||||
}
|
||||
|
||||
lock := newStageLock()
|
||||
tags := []string{"A", "B", "C", "D", "E"}
|
||||
const iters = 40
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errCh := make(chan error, len(tags)*iters)
|
||||
|
||||
for _, tag := range tags {
|
||||
wg.Add(1)
|
||||
go func(tag string) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iters; i++ {
|
||||
if err := lock.Acquire(tag); err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
var local pubsdk.StageContext
|
||||
if err := seg.ReadInto(&local); err != nil {
|
||||
lock.Release(tag)
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
snap := TakeSnapshot(&local)
|
||||
local.FinalText += tag // 读-改-写
|
||||
if _, err := seg.WriteDirty(&local, snap); err != nil {
|
||||
lock.Release(tag)
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
if err := lock.Release(tag); err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}(tag)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
for err := range errCh {
|
||||
if err != nil {
|
||||
t.Fatalf("并发写入失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var final pubsdk.StageContext
|
||||
if err := seg.ReadInto(&final); err != nil {
|
||||
t.Fatalf("ReadInto: %v", err)
|
||||
}
|
||||
|
||||
// 关键断言:各标记出现次数之和 == 最终长度 ⇒ 无丢失、无撕裂
|
||||
total := 0
|
||||
counts := map[string]int{}
|
||||
for _, tag := range tags {
|
||||
c := strings.Count(final.FinalText, tag)
|
||||
counts[tag] = c
|
||||
total += c
|
||||
}
|
||||
if total != len(final.FinalText) {
|
||||
t.Fatalf("出现撕裂:各标记计数之和 %d != 最终长度 %d(counts=%v)",
|
||||
total, len(final.FinalText), counts)
|
||||
}
|
||||
want := len(tags) * iters
|
||||
if total != want {
|
||||
t.Fatalf("出现 lost update:期望 %d 次写入全部保留,实际 %d(counts=%v)",
|
||||
want, total, counts)
|
||||
}
|
||||
for tag, c := range counts {
|
||||
if c != iters {
|
||||
t.Errorf("插件 %s 的写入丢失:期望 %d 次,实际 %d 次", tag, iters, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// arena 用尽必须显式报错,不得静默截断(§4.4 风险登记)。
|
||||
func TestSegment_ArenaExhaustionReturnsError(t *testing.T) {
|
||||
seg := newTestSegment(t, headerSize+ctxSize+256) // 极小 arena
|
||||
big := strings.Repeat("x", 1024)
|
||||
err := seg.WriteAll(&pubsdk.StageContext{FinalText: big})
|
||||
if err == nil {
|
||||
t.Fatal("arena 不足应报错,而非静默截断")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "arena 空间不足") {
|
||||
t.Errorf("错误信息应说明 arena 不足,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 压实回收 append-only 垃圾,且不破坏现存字段。
|
||||
func TestSegment_CompactReclaimsGarbage(t *testing.T) {
|
||||
seg := newTestSegment(t, 32*1024)
|
||||
if err := seg.WriteAll(&pubsdk.StageContext{FinalText: "初始"}); err != nil {
|
||||
t.Fatalf("WriteAll: %v", err)
|
||||
}
|
||||
|
||||
// 反复改写同一字段,制造 append-only 垃圾
|
||||
for i := 0; i < 50; i++ {
|
||||
var local pubsdk.StageContext
|
||||
if err := seg.ReadInto(&local); err != nil {
|
||||
t.Fatalf("ReadInto: %v", err)
|
||||
}
|
||||
snap := TakeSnapshot(&local)
|
||||
local.FinalText = fmt.Sprintf("第%d次改写内容", i)
|
||||
if _, err := seg.WriteDirty(&local, snap); err != nil {
|
||||
t.Fatalf("WriteDirty: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
usedBefore := seg.ArenaUsed()
|
||||
var beforeCtx pubsdk.StageContext
|
||||
if err := seg.ReadInto(&beforeCtx); err != nil {
|
||||
t.Fatalf("ReadInto: %v", err)
|
||||
}
|
||||
|
||||
reclaimed := seg.Compact()
|
||||
if reclaimed == 0 {
|
||||
t.Error("应回收到垃圾空间")
|
||||
}
|
||||
if seg.ArenaUsed() >= usedBefore {
|
||||
t.Errorf("压实后已用空间应下降:%d → %d", usedBefore, seg.ArenaUsed())
|
||||
}
|
||||
|
||||
var afterCtx pubsdk.StageContext
|
||||
if err := seg.ReadInto(&afterCtx); err != nil {
|
||||
t.Fatalf("压实后 ReadInto: %v", err)
|
||||
}
|
||||
if afterCtx.FinalText != beforeCtx.FinalText {
|
||||
t.Errorf("压实破坏了字段内容:%q → %q", beforeCtx.FinalText, afterCtx.FinalText)
|
||||
}
|
||||
}
|
||||
50
internal/plugin/proc/shmalloc_darwin.go
Normal file
50
internal/plugin/proc/shmalloc_darwin.go
Normal 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
|
||||
}
|
||||
43
internal/plugin/proc/shmalloc_linux.go
Normal file
43
internal/plugin/proc/shmalloc_linux.go
Normal 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
|
||||
}
|
||||
28
internal/plugin/proc/shmalloc_other.go
Normal file
28
internal/plugin/proc/shmalloc_other.go
Normal 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
|
||||
}
|
||||
137
internal/plugin/proc/shmalloc_windows.go
Normal file
137
internal/plugin/proc/shmalloc_windows.go
Normal 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.go),stage 只下发 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 为 nil:Windows 不经 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 ""
|
||||
}
|
||||
514
internal/plugin/proc/shmcodec.go
Normal file
514
internal/plugin/proc/shmcodec.go
Normal file
@ -0,0 +1,514 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// StageContext 的跨进程编解码(§3.3 数据面 / §3.4 SDK 封装全部复杂度)。
|
||||
//
|
||||
// 设计要点:
|
||||
//
|
||||
// 1. **插件作者永远不接触 Slice{off,len}**。插件进程内保留原生
|
||||
// *pubsdk.StageContext,handler 照常读写字段;stage 入口从共享段
|
||||
// 反序列化成本地对象,handler 返回时把**脏字段**写回共享段。
|
||||
//
|
||||
// 2. **字段级描述符消除 lost update**。只改 FinalText 的插件不触碰
|
||||
// ToolResults 的描述符,故不存在"只读插件把旧快照写回覆盖他人改写"
|
||||
// (今日副本模型实测 35.8~36.8% 丢失,§8.4)。
|
||||
//
|
||||
// 3. **全部 16 个字段可见**。今日经 C ABI 只下发 10 个字段,外部插件永远
|
||||
// 看不到 ContextMsgs/ReasoningContent/TokenUsage/Memory/Extra/Errors(§8.3);
|
||||
// 共享内存下全部可见可改——接口形式不变,能力变强。
|
||||
//
|
||||
// 4. **Extra 的 4 个键提升为具名字段**(§3.3 已核实全部使用点仅这 4 个):
|
||||
// media_blocks / media_type / input_source / output_channel。
|
||||
// 它们都是内核写、插件读,无并发改写需求;真正需要多插件并发改的
|
||||
// (LLMText/FinalText/ToolCalls/Errors)全是强类型字段。
|
||||
|
||||
// extra 中被提升为具名字段的键。
|
||||
const (
|
||||
ExtraKeyMediaBlocks = "media_blocks"
|
||||
ExtraKeyMediaType = "media_type"
|
||||
ExtraKeyInputSource = "input_source"
|
||||
ExtraKeyOutputChannel = "output_channel"
|
||||
)
|
||||
|
||||
// WriteAll 把整个 StageContext 写入共享段(内核侧在 stage 开始前调用一次)。
|
||||
// 调用方须持有写锁。
|
||||
func (s *Segment) WriteAll(sc *pubsdk.StageContext) error {
|
||||
sc.RLock()
|
||||
snap := captureLocal(sc)
|
||||
sc.RUnlock()
|
||||
return s.writeLocal(snap)
|
||||
}
|
||||
|
||||
// localCtx 是 StageContext 的值快照,用于在不持有 sc 锁的情况下做编解码。
|
||||
type localCtx struct {
|
||||
RawMessage string
|
||||
UserID string
|
||||
GroupID string
|
||||
LLMText string
|
||||
ReasoningContent string
|
||||
FinalText string
|
||||
Response *string
|
||||
Phase string
|
||||
NoMemory bool
|
||||
ContextMsgs []map[string]interface{}
|
||||
ToolCalls []pubsdk.ToolCall
|
||||
ToolResults []pubsdk.ToolResult
|
||||
Memory []pubsdk.MemItem
|
||||
TokenUsage map[string]int
|
||||
Errors []string
|
||||
ExtraMediaBlocks interface{}
|
||||
ExtraMediaType interface{}
|
||||
ExtraInputSource interface{}
|
||||
ExtraOutputChan interface{}
|
||||
}
|
||||
|
||||
func captureLocal(sc *pubsdk.StageContext) *localCtx {
|
||||
l := &localCtx{
|
||||
RawMessage: sc.RawMessage,
|
||||
UserID: sc.UserID,
|
||||
GroupID: sc.GroupID,
|
||||
LLMText: sc.LLMText,
|
||||
ReasoningContent: sc.ReasoningContent,
|
||||
FinalText: sc.FinalText,
|
||||
Response: sc.Response,
|
||||
Phase: string(sc.Phase),
|
||||
NoMemory: sc.NoMemory,
|
||||
ContextMsgs: sc.ContextMsgs,
|
||||
ToolCalls: sc.ToolCalls,
|
||||
ToolResults: sc.ToolResults,
|
||||
Memory: sc.Memory,
|
||||
TokenUsage: sc.TokenUsage,
|
||||
Errors: sc.Errors,
|
||||
}
|
||||
if sc.Extra != nil {
|
||||
l.ExtraMediaBlocks = sc.Extra[ExtraKeyMediaBlocks]
|
||||
l.ExtraMediaType = sc.Extra[ExtraKeyMediaType]
|
||||
l.ExtraInputSource = sc.Extra[ExtraKeyInputSource]
|
||||
l.ExtraOutputChan = sc.Extra[ExtraKeyOutputChannel]
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (s *Segment) writeLocal(l *localCtx) error {
|
||||
putStr := func(f stageField, v string) error {
|
||||
sl, err := s.write([]byte(v))
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入字段 %s: %w", f, err)
|
||||
}
|
||||
s.setDesc(f, sl)
|
||||
return nil
|
||||
}
|
||||
putJSON := func(f stageField, v interface{}) error {
|
||||
if v == nil {
|
||||
s.setDesc(f, Slice{})
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化字段 %s: %w", f, err)
|
||||
}
|
||||
sl, err := s.write(b)
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入字段 %s: %w", f, err)
|
||||
}
|
||||
s.setDesc(f, sl)
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, step := range []struct {
|
||||
f stageField
|
||||
v string
|
||||
}{
|
||||
{fRawMessage, l.RawMessage},
|
||||
{fUserID, l.UserID},
|
||||
{fGroupID, l.GroupID},
|
||||
{fLLMText, l.LLMText},
|
||||
{fReasoningContent, l.ReasoningContent},
|
||||
{fFinalText, l.FinalText},
|
||||
{fPhase, l.Phase},
|
||||
} {
|
||||
if err := putStr(step.f, step.v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Response 是 *string:用标志位表达 nil,避免 "" 与 nil 混淆
|
||||
if l.Response != nil {
|
||||
if err := putStr(fResponse, *l.Response); err != nil {
|
||||
return err
|
||||
}
|
||||
s.setFlag(flagResponseSet, true)
|
||||
} else {
|
||||
s.setDesc(fResponse, Slice{})
|
||||
s.setFlag(flagResponseSet, false)
|
||||
}
|
||||
s.setFlag(flagNoMemory, l.NoMemory)
|
||||
|
||||
// 切片/映射字段:nil 与空切片都写成"未设置",避免插件收到 [] 后误以为
|
||||
// 内核显式清空过(与今日 writable 的 len>0 才下发语义一致)。
|
||||
jsonFields := []struct {
|
||||
f stageField
|
||||
v interface{}
|
||||
}{
|
||||
{fContextMsgs, sliceOrNil(len(l.ContextMsgs), l.ContextMsgs)},
|
||||
{fToolCalls, sliceOrNil(len(l.ToolCalls), l.ToolCalls)},
|
||||
{fToolResults, sliceOrNil(len(l.ToolResults), l.ToolResults)},
|
||||
{fMemory, sliceOrNil(len(l.Memory), l.Memory)},
|
||||
{fTokenUsage, sliceOrNil(len(l.TokenUsage), l.TokenUsage)},
|
||||
{fErrors, sliceOrNil(len(l.Errors), l.Errors)},
|
||||
{fExtraMediaBlocks, l.ExtraMediaBlocks},
|
||||
{fExtraMediaType, l.ExtraMediaType},
|
||||
{fExtraInputSource, l.ExtraInputSource},
|
||||
{fExtraOutputChannel, l.ExtraOutputChan},
|
||||
}
|
||||
for _, step := range jsonFields {
|
||||
if err := putJSON(step.f, step.v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.bumpSeq()
|
||||
return nil
|
||||
}
|
||||
|
||||
// sliceOrNil 让长度为 0 的容器写成 nil(未设置),非零则原样返回。
|
||||
func sliceOrNil(n int, v interface{}) interface{} {
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ReadInto 从共享段读出全部字段填充到 sc(插件进程侧 stage 入口调用)。
|
||||
// 调用方须持有读锁或写锁。
|
||||
func (s *Segment) ReadInto(sc *pubsdk.StageContext) error {
|
||||
getStr := func(f stageField) (string, error) {
|
||||
b, err := s.read(s.getDesc(f))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取字段 %s: %w", f, err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
getJSON := func(f stageField, out interface{}) error {
|
||||
b, err := s.read(s.getDesc(f))
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取字段 %s: %w", f, err)
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(b, out); err != nil {
|
||||
return fmt.Errorf("反序列化字段 %s: %w", f, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
raw, err := getStr(fRawMessage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uid, err := getStr(fUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gid, err := getStr(fGroupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
llm, err := getStr(fLLMText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reason, err := getStr(fReasoningContent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
final, err := getStr(fFinalText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
phase, err := getStr(fPhase)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var ctxMsgs []map[string]interface{}
|
||||
var toolCalls []pubsdk.ToolCall
|
||||
var toolResults []pubsdk.ToolResult
|
||||
var mem []pubsdk.MemItem
|
||||
var usage map[string]int
|
||||
var errs []string
|
||||
if err := getJSON(fContextMsgs, &ctxMsgs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := getJSON(fToolCalls, &toolCalls); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := getJSON(fToolResults, &toolResults); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := getJSON(fMemory, &mem); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := getJSON(fTokenUsage, &usage); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := getJSON(fErrors, &errs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
extra := map[string]interface{}{}
|
||||
for _, pair := range []struct {
|
||||
f stageField
|
||||
key string
|
||||
}{
|
||||
{fExtraMediaBlocks, ExtraKeyMediaBlocks},
|
||||
{fExtraMediaType, ExtraKeyMediaType},
|
||||
{fExtraInputSource, ExtraKeyInputSource},
|
||||
{fExtraOutputChannel, ExtraKeyOutputChannel},
|
||||
} {
|
||||
var v interface{}
|
||||
if err := getJSON(pair.f, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
if v != nil {
|
||||
extra[pair.key] = v
|
||||
}
|
||||
}
|
||||
|
||||
var respPtr *string
|
||||
if s.getFlag(flagResponseSet) {
|
||||
r, err := getStr(fResponse)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
respPtr = &r
|
||||
}
|
||||
|
||||
sc.Lock()
|
||||
defer sc.Unlock()
|
||||
sc.RawMessage = raw
|
||||
sc.UserID = uid
|
||||
sc.GroupID = gid
|
||||
sc.LLMText = llm
|
||||
sc.ReasoningContent = reason
|
||||
sc.FinalText = final
|
||||
sc.Phase = pubsdk.Stage(phase)
|
||||
sc.NoMemory = s.getFlag(flagNoMemory)
|
||||
sc.Response = respPtr
|
||||
sc.ContextMsgs = ctxMsgs
|
||||
sc.ToolCalls = toolCalls
|
||||
sc.ToolResults = toolResults
|
||||
sc.Memory = mem
|
||||
sc.TokenUsage = usage
|
||||
sc.Errors = errs
|
||||
if len(extra) > 0 {
|
||||
sc.Extra = extra
|
||||
} else {
|
||||
sc.Extra = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteDirty 只把与 base 快照不同的字段写回共享段(插件进程侧 handler 返回后调用)。
|
||||
//
|
||||
// **这是消除 lost update 的关键**:只读插件的 dirty 集为空 → 零写入 →
|
||||
// 不可能覆盖其他插件的改写。对比今日副本模型无条件回传 10 个字段的行为
|
||||
// (§8.4 实测 35.8~36.8% 丢失,现网量级百分之几的脏数据进 LLM)。
|
||||
//
|
||||
// 返回实际写回的字段数,便于诊断与测试断言。
|
||||
func (s *Segment) WriteDirty(sc *pubsdk.StageContext, base *Snapshot) (int, error) {
|
||||
sc.RLock()
|
||||
cur := captureLocal(sc)
|
||||
sc.RUnlock()
|
||||
|
||||
now := newSnapshotFromLocal(cur)
|
||||
changed := 0
|
||||
|
||||
putStr := func(f stageField, v string) error {
|
||||
sl, err := s.write([]byte(v))
|
||||
if err != nil {
|
||||
return fmt.Errorf("写回字段 %s: %w", f, err)
|
||||
}
|
||||
s.setDesc(f, sl)
|
||||
changed++
|
||||
return nil
|
||||
}
|
||||
putRaw := func(f stageField, raw string) error {
|
||||
if raw == "" {
|
||||
s.setDesc(f, Slice{})
|
||||
changed++
|
||||
return nil
|
||||
}
|
||||
sl, err := s.write([]byte(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("写回字段 %s: %w", f, err)
|
||||
}
|
||||
s.setDesc(f, sl)
|
||||
changed++
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, step := range []struct {
|
||||
f stageField
|
||||
v string
|
||||
}{
|
||||
{fRawMessage, cur.RawMessage},
|
||||
{fUserID, cur.UserID},
|
||||
{fGroupID, cur.GroupID},
|
||||
{fLLMText, cur.LLMText},
|
||||
{fReasoningContent, cur.ReasoningContent},
|
||||
{fFinalText, cur.FinalText},
|
||||
{fPhase, cur.Phase},
|
||||
} {
|
||||
if base.strs[step.f] != now.strs[step.f] {
|
||||
if err := putStr(step.f, step.v); err != nil {
|
||||
return changed, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JSON 字段:比较序列化结果
|
||||
for f := range now.jsons {
|
||||
if base.jsons[f] != now.jsons[f] {
|
||||
if err := putRaw(f, now.jsons[f]); err != nil {
|
||||
return changed, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Response 的 nil 语义变化也算脏
|
||||
if base.responseSet != now.responseSet || base.response != now.response {
|
||||
if now.responseSet {
|
||||
if err := putStr(fResponse, now.response); err != nil {
|
||||
return changed, err
|
||||
}
|
||||
s.setFlag(flagResponseSet, true)
|
||||
} else {
|
||||
// 插件把 Response 置回 nil:短路语义不应被撑销,故不清空内核已设的值。
|
||||
// 与 C ABI 路径 applyStageResult 的行为保持一致。
|
||||
s.setFlag(flagResponseSet, s.getFlag(flagResponseSet))
|
||||
}
|
||||
}
|
||||
if base.noMemory != now.noMemory {
|
||||
s.setFlag(flagNoMemory, now.noMemory)
|
||||
changed++
|
||||
}
|
||||
|
||||
if changed > 0 {
|
||||
s.bumpSeq()
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// Snapshot 是 handler 运行前的字段快照,用于计算脏字段。
|
||||
//
|
||||
// ❗ 必须存**序列化后的字符串**而非 Go 值:StageContext 的切片字段与
|
||||
// 调用方共享底层数组,handler 原地改元素(sc.ToolResults[0].Result = x)
|
||||
// 时直接持有的 Go 值快照会跟着变,脏字段计算失效——这个坑在 C ABI 侧
|
||||
// 修 11.3 时已经踩过一次(见 SDK 仓 stagediff_test.go 的注释)。
|
||||
type Snapshot struct {
|
||||
strs map[stageField]string
|
||||
jsons map[stageField]string
|
||||
response string
|
||||
responseSet bool
|
||||
noMemory bool
|
||||
}
|
||||
|
||||
// Snapshot 抓取当前 StageContext 的快照(插件进程侧 handler 前调用)。
|
||||
func TakeSnapshot(sc *pubsdk.StageContext) *Snapshot {
|
||||
sc.RLock()
|
||||
l := captureLocal(sc)
|
||||
sc.RUnlock()
|
||||
return newSnapshotFromLocal(l)
|
||||
}
|
||||
|
||||
func newSnapshotFromLocal(l *localCtx) *Snapshot {
|
||||
sn := &Snapshot{
|
||||
strs: map[stageField]string{},
|
||||
jsons: map[stageField]string{},
|
||||
}
|
||||
sn.strs[fRawMessage] = l.RawMessage
|
||||
sn.strs[fUserID] = l.UserID
|
||||
sn.strs[fGroupID] = l.GroupID
|
||||
sn.strs[fLLMText] = l.LLMText
|
||||
sn.strs[fReasoningContent] = l.ReasoningContent
|
||||
sn.strs[fFinalText] = l.FinalText
|
||||
sn.strs[fPhase] = l.Phase
|
||||
|
||||
marshal := func(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
sn.jsons[fContextMsgs] = marshal(sliceOrNil(len(l.ContextMsgs), l.ContextMsgs))
|
||||
sn.jsons[fToolCalls] = marshal(sliceOrNil(len(l.ToolCalls), l.ToolCalls))
|
||||
sn.jsons[fToolResults] = marshal(sliceOrNil(len(l.ToolResults), l.ToolResults))
|
||||
sn.jsons[fMemory] = marshal(sliceOrNil(len(l.Memory), l.Memory))
|
||||
sn.jsons[fTokenUsage] = marshal(sliceOrNil(len(l.TokenUsage), l.TokenUsage))
|
||||
sn.jsons[fErrors] = marshal(sliceOrNil(len(l.Errors), l.Errors))
|
||||
sn.jsons[fExtraMediaBlocks] = marshal(l.ExtraMediaBlocks)
|
||||
sn.jsons[fExtraMediaType] = marshal(l.ExtraMediaType)
|
||||
sn.jsons[fExtraInputSource] = marshal(l.ExtraInputSource)
|
||||
sn.jsons[fExtraOutputChannel] = marshal(l.ExtraOutputChan)
|
||||
|
||||
if l.Response != nil {
|
||||
sn.response = *l.Response
|
||||
sn.responseSet = true
|
||||
}
|
||||
sn.noMemory = l.NoMemory
|
||||
return sn
|
||||
}
|
||||
|
||||
// String 让字段枚举在错误信息里可读。
|
||||
func (f stageField) String() string {
|
||||
switch f {
|
||||
case fRawMessage:
|
||||
return "raw_message"
|
||||
case fUserID:
|
||||
return "user_id"
|
||||
case fGroupID:
|
||||
return "group_id"
|
||||
case fLLMText:
|
||||
return "llm_text"
|
||||
case fReasoningContent:
|
||||
return "reasoning_content"
|
||||
case fFinalText:
|
||||
return "final_text"
|
||||
case fResponse:
|
||||
return "response"
|
||||
case fPhase:
|
||||
return "phase"
|
||||
case fContextMsgs:
|
||||
return "context_msgs"
|
||||
case fToolCalls:
|
||||
return "tool_calls"
|
||||
case fToolResults:
|
||||
return "tool_results"
|
||||
case fMemory:
|
||||
return "memory"
|
||||
case fTokenUsage:
|
||||
return "token_usage"
|
||||
case fErrors:
|
||||
return "errors"
|
||||
case fExtraMediaBlocks:
|
||||
return "extra." + ExtraKeyMediaBlocks
|
||||
case fExtraMediaType:
|
||||
return "extra." + ExtraKeyMediaType
|
||||
case fExtraInputSource:
|
||||
return "extra." + ExtraKeyInputSource
|
||||
case fExtraOutputChannel:
|
||||
return "extra." + ExtraKeyOutputChannel
|
||||
}
|
||||
return fmt.Sprintf("field(%d)", int(f))
|
||||
}
|
||||
21
internal/plugin/proc/shmpass_unix.go
Normal file
21
internal/plugin/proc/shmpass_unix.go
Normal 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}
|
||||
}
|
||||
26
internal/plugin/proc/shmpass_windows.go
Normal file
26
internal/plugin/proc/shmpass_windows.go
Normal 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 }
|
||||
111
internal/plugin/proc/stage.go
Normal file
111
internal/plugin/proc/stage.go
Normal 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:与内核工具超时(60s,toolcall.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
|
||||
}
|
||||
157
internal/plugin/proc/streaming_test.go
Normal file
157
internal/plugin/proc/streaming_test.go
Normal 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→8,Publish 从 %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)
|
||||
}
|
||||
}
|
||||
163
internal/plugin/proc/supervisor.go
Normal file
163
internal/plugin/proc/supervisor.go
Normal 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 成功握手后 track,Process.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
232
internal/plugin/proc/testdata/appendplugin.go
vendored
Normal file
232
internal/plugin/proc/testdata/appendplugin.go
vendored
Normal file
@ -0,0 +1,232 @@
|
||||
//go:build ignore
|
||||
|
||||
// appendplugin 在 stage 中把自己的标记追加到 FinalText(读-改-写)。
|
||||
//
|
||||
// 用于跨进程 lost update 验证:多个此类插件并发处理同一 stage,
|
||||
// 若全部标记都保留 ⇒ 无丢失;若少了 ⇒ 出现 lost update。
|
||||
//
|
||||
// 这是实验 8(5 进程 × 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})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
52
internal/plugin/proc/testdata/badprotoplugin.go
vendored
Normal file
52
internal/plugin/proc/testdata/badprotoplugin.go
vendored
Normal 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})
|
||||
}
|
||||
}
|
||||
}
|
||||
120
internal/plugin/proc/testdata/callbackplugin.go
vendored
Normal file
120
internal/plugin/proc/testdata/callbackplugin.go
vendored
Normal 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
Reference in New Issue
Block a user