plugindev: entry 语义收敛 + 删 C ABI 工具链 + Windows 共享内存适配(Part 6.1)

## entry 不再是通道开关 —— 外部插件零改动的关键

17 个存量插件的 plg.json 都写着 "entry": "plugin.so"。若把 entry 当通道
开关,迁移就得改 17 个文件,而「外部插件零改动」是本次迁移的硬约束。

改法:Go 插件一律产出 plugin.bin,不看 entry 值。isProcEntry 删除,
resolveBuild 去掉 proc 参数。entry 现在只剩区分 Lua(main.lua)一个用途。

实测:weather 的 plg.json 一行不改(仍写 plugin.so),plugindev build
直接产出三平台 plugin.bin。

## Windows 不再是能力退化的第三套实现(§9.2 的正解)

C ABI 时代 Windows 是独立的第三套 ABI:dynamic_dll_windows.go 的 stage
只下发 3 个字段(raw_message/user_id/phase)且完全没有写回,sanitizer
这类改写型插件在 Windows 上静默失效,且无任何运行时警告。

现在 Windows 与 Unix 共用同一份 RPC 逻辑与同一份共享段布局。平台差异
收敛到三个挂载函数:
- Unix(linux/darwin/freebsd):内核经 ExtraFiles 传继承 fd(3=StageContext
  段,4=事件环段,5=eventfd/pipe)
- Windows:没有 fd 继承语义(os/exec 的 ExtraFiles 在 Windows 不支持),
  改用命名内核对象——父进程 CreateFileMapping/CreateEvent 建带名字的对象,
  子进程 OpenFileMappingW/OpenEventW 按同名打开。名字经环境变量传入而非
  硬编码:多个 homed 实例并存时不能撞名。

Windows 绑定用 syscall.NewLazyDLL 而非 golang.org/x/sys/windows:
OpenFileMappingW/OpenEventW 未被标准库 syscall 导出,而引入 x/sys 会给
**每个插件的 go.mod** 加一个新依赖,违反「插件仅依赖公开 SDK」。
LazyDLL 属标准库,零新增依赖。

新增 evtWaiter 接口抽象等待语义:eventfd 是计数器(多事件合并成一次
唤醒),Windows Event 是二元信号。不影响正确性——消费者被唤醒后按
readSeq 追 writeSeq 批量 drain,一次唤醒能处理累积的全部事件。

模板拆成三个文件:
  proc_main.go.tmpl          平台无关(RPC + 共享段布局 + stage + 事件环消费)
  proc_shm_unix.go.tmpl      继承 fd 挂载
  proc_shm_windows.go.tmpl   命名对象挂载

## 删除 C ABI 工具链

templates.go 1296 → 516 行:
- tmplBridge(Windows DLL bridge)      -265 行
- tmplLinuxBridge(Linux c-shared)     -457 行
- tmplPluginInitC(C 入口)              -57 行
另删 generateBridge / detectWindowsCC(MinGW 探测)/ tmplCABIHeader /
InitData.CABIVersion+CABIHeader。

交叉编译不再需要目标平台 C 工具链——这是 -buildmode=c-shared 退场的
连带收益(§3.1)。

## 测试

15 项全过,新增 4 项守护迁移不变量:
- AllPlatformsProduceBin:6 个 GOOS/GOARCH 组合统一产出 plugin.bin
- LuaIsSeparatePath:Lua 仍走解释器路径
- UnsupportedOSErrors:不支持平台明确报错,不静默产出错误产物
- NoCABIResiduals:代码中不得再出现 c-shared / CGO_ENABLED=1 /
  detectWindowsCC / tmplLinuxBridge / tmplPluginInitC(注释除外)
- IgnoresEntryForGoPlugins:isProcEntry 必须已删除

验证:go build/vet/test 全通过;三平台交叉编译产出 plugin.bin;
git diff sdk/ 为空(接口冻结)。

Ref: docs/zh/架构迁移评估.md §3.1/§9.2、docs/zh/plugin-migration-plan.md Part 6
This commit is contained in:
JianFeeeee
2026-09-02 18:39:02 +08:00
parent ef0e58ee23
commit 9f844123fe
8 changed files with 439 additions and 1071 deletions

View File

@ -3,6 +3,7 @@ package main
import (
"go/parser"
"go/token"
"os"
"regexp"
"strings"
"testing"
@ -285,70 +286,109 @@ func TestProcTemplate_RejectsVersionMismatch(t *testing.T) {
}
}
// isProcEntry 只认 plugin.bin。
func TestIsProcEntry(t *testing.T) {
if !isProcEntry("plugin.bin") {
t.Error("plugin.bin 应为 proc 模式")
}
for _, e := range []string{"plugin.so", "plugin.dll", "plugin.dylib", "main.lua", "", "plugin.exe"} {
if isProcEntry(e) {
t.Errorf("%q 不应被判为 proc 模式", e)
}
}
}
// proc 模式下各平台产物统一为 plugin.bin进程边界即 ABI 边界,无平台扩展名)。
func TestResolveBuild_ProcModeUsesBinOnAllPlatforms(t *testing.T) {
for _, target := range []string{"linux/amd64", "darwin/arm64", "windows/amd64", "freebsd/amd64"} {
cfg, errMsg := resolveBuild(target, true)
if cfg == nil {
t.Fatalf("resolveBuild(%q, proc) 失败: %s", target, errMsg)
}
if cfg.entryFile != procEntryFile {
t.Errorf("%s: proc 模式产物应为 %s实际 %s", target, procEntryFile, cfg.entryFile)
}
if !cfg.proc {
t.Errorf("%s: proc 标志应为 true", target)
}
}
}
// 非 proc 模式行为不变(回归保护:.so 通道必须与改动前一致)。
func TestResolveBuild_CABIModeUnchanged(t *testing.T) {
cases := map[string]string{
"linux/amd64": "plugin.so",
"darwin/amd64": "plugin.dylib",
"freebsd/amd64": "plugin.so",
"windows/amd64": "plugin.dll",
}
for target, want := range cases {
cfg, errMsg := resolveBuild(target, false)
// 全平台统一产出 plugin.bin。
//
// 这是三套独立 ABI 实现(.so/.dylib/.dll收敛为单一 RPC 实现的直接后果:
// 进程边界本身就是 ABI 边界,不存在平台特有的动态库扩展名。
// §9.2 记录的「Windows DLL 路径只下发 3 字段、无写回」随之消失——
// Windows 走的是与 Linux 完全相同的 RPC 实现。
func TestResolveBuild_AllPlatformsProduceBin(t *testing.T) {
for _, target := range []string{
"linux/amd64", "linux/arm64",
"darwin/amd64", "darwin/arm64",
"windows/amd64",
"freebsd/amd64",
} {
cfg, errMsg := resolveBuild(target)
if cfg == nil {
t.Fatalf("resolveBuild(%q) 失败: %s", target, errMsg)
}
if cfg.entryFile != want {
t.Errorf("%s: 应产出 %s实际 %s", target, want, cfg.entryFile)
}
if cfg.proc {
t.Errorf("%s: 非 proc 模式的 proc 标志应为 false", target)
if cfg.entryFile != procEntryFile {
t.Errorf("%s: 产物应为 %s实际 %s", target, procEntryFile, cfg.entryFile)
}
}
}
// bundle 模式下 proc 产物在 zip 内按平台加后缀(同名会相互覆盖)。
func TestProcBundleTargets_HavePlatformSuffixedEntries(t *testing.T) {
// lua 目标仍走解释器路径entry 字段唯一仍在使用的用途)。
func TestResolveBuild_LuaIsSeparatePath(t *testing.T) {
for _, target := range []string{"lua", ""} {
cfg, kind := resolveBuild(target)
if cfg != nil {
t.Errorf("%q 应返回 nil cfgLua 不经 Go 编译)", target)
}
if kind != "lua" {
t.Errorf("%q 应识别为 lua实际 %q", target, kind)
}
}
}
// 不支持的平台明确报错,不静默产出错误产物。
func TestResolveBuild_UnsupportedOSErrors(t *testing.T) {
cfg, errMsg := resolveBuild("plan9/amd64")
if cfg != nil {
t.Error("不支持的平台应返回 nil cfg")
}
if !strings.Contains(errMsg, "unsupported") {
t.Errorf("应给出 unsupported 提示,实际 %q", errMsg)
}
}
// bundle 产物在 zip 内按平台加后缀(全平台同名 plugin.bin 会相互覆盖)。
func TestBundleTargets_HavePlatformSuffixedEntries(t *testing.T) {
seen := map[string]bool{}
for _, bt := range allProcBundleTargets {
for _, bt := range allBundleTargets {
if seen[bt.entry] {
t.Errorf("zip 条目名重复: %s会相互覆盖", bt.entry)
}
seen[bt.entry] = true
if !strings.HasPrefix(bt.entry, procEntryFile+".") {
t.Errorf("proc bundle 条目 %q 应以 %s. 为前缀", bt.entry, procEntryFile)
t.Errorf("bundle 条目 %q 应以 %s. 为前缀", bt.entry, procEntryFile)
}
}
if len(allProcBundleTargets) != len(allBundleTargets) {
t.Errorf("proc 与 cabi 的 bundle 平台数应一致:%d vs %d",
len(allProcBundleTargets), len(allBundleTargets))
if len(allBundleTargets) == 0 {
t.Error("bundle 目标表不应为空")
}
}
// C ABI 工具链残留必须彻底清除:不得再有 .so/.dylib/.dll 产物路径,
// 也不得再引用 c-shared 构建模式或 MinGW 探测。
func TestToolchain_NoCABIResiduals(t *testing.T) {
for _, f := range []string{"cmd_build.go", "templates.go", "cmd_init.go", "proc_runtime.go"} {
data, err := os.ReadFile(f)
if err != nil {
t.Fatalf("读 %s: %v", f, err)
}
src := stripComments(t, string(data))
for _, forbidden := range []string{
"c-shared",
"CGO_ENABLED=1",
"detectWindowsCC",
"generateBridge",
"tmplLinuxBridge",
"tmplPluginInitC",
} {
if strings.Contains(src, forbidden) {
t.Errorf("%s 仍含 C ABI 残留 %q", f, forbidden)
}
}
}
}
// Go 插件的构建不再读 plg.json 的 entry 值。
//
// 这是「外部插件零改动」的关键17 个存量插件的 plg.json 都写着 "plugin.so"
// 若把 entry 当通道开关,迁移就得改 17 个文件。
func TestToolchain_IgnoresEntryForGoPlugins(t *testing.T) {
data, err := os.ReadFile("cmd_build.go")
if err != nil {
t.Fatalf("读 cmd_build.go: %v", err)
}
src := stripComments(t, string(data))
if strings.Contains(src, "isProcEntry") {
t.Error("isProcEntry 应已删除——Go 插件一律产出 plugin.bin不看 entry 值")
}
// entry 仅剩 Lua 判定这一处用途
if !strings.Contains(src, "luaEntryFile") {
t.Error("IsLua 应改用 luaEntryFile 常量")
}
}