feat(proc): 共享段 arena 容量可配置(启动期生效,需重启内核)

## 改了什么

- `proc.NewHostWithArenaSize(bytes)`:按给定容量建段;保留 `NewHost()`
  以兼容默认 4MB。
- 容量低于分配器最小尺寸时**返回明确错误**,不静默回落默认值
  (否则"我配了 32MB"会悄悄变成"仍是 4MB")。
- 删除 `arenaPublishSize` 变量:自从容量改为入参后已无人读取,
  留一个"看着能改、改了没效果"的开关比没有更危险。
- `core.plugin.shm_arena_mb`(默认 4,clamp 1..256):播种 + 定义 +
  `Registry.arenaBytesFromConfig()` 读取。越界时记日志说明被 clamp。

## 为什么容量只能在启动时决定

arena 是统一区域里一段连续 mmap,而 `SharedRef` 存**绝对偏移**。
变大需要 remap,而每个 plugin.bin 子进程可能正在读同一页 ——
跨进程 munmap 会让它 SIGSEGV。该路径历史上三次被明确搁置。
⇒ 本改动只决定"这次启动开多大",配置项描述里已写明需重启。

## 判据(`internal/plugin/proc/test/arena_contract_test.go`)

    TestArenaHostHonoursRequestedCapacity
      给定 8MB → 能写入并读回 4MB(证明容量真的生效)
    TestArenaHostRejectsTooSmallCapacity
      64 字节 → 明确报错

变异验证:
    忽略传入容量、总用默认 4MB   → 4MB 写入报「超出 arena 容量 4194228」
    过小容量改为静默回落默认     → 判据红

    go build ./... / go vet ./...                  PASS
    python3 .github/scripts/check-ci-gates.py      PASS
    go test ./... -count=1 -race                   51 包 / 100 包无测试 / 0 FAIL
    gofmt -l                                       干净
This commit is contained in:
JianFeeeee
2026-11-14 11:32:03 +08:00
parent d0e4f10b53
commit b879558947
5 changed files with 92 additions and 5 deletions

View File

@ -837,6 +837,7 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) bool {
}
set("core.plugin.dir", filepath.Join(dataDir, "plugins"))
set("core.plugin.shm_arena_mb", "4")
set("core.memory.graph", filepath.Join(dataDir, "memory", "graph.db"))
set("core.memory.text", filepath.Join(dataDir, "memory", "text"))
set("core.memory.documents", filepath.Join(dataDir, "memory", "documents"))
@ -974,6 +975,11 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string, fresh bool) {
plgDir := filepath.Join(dataDir, "plugins")
reg(ConfigDef{Key: "core.plugin.dir", Default: plgDir, Type: "string", DisplayName: "插件目录", Description: "外部插件安装目录", Category: "paths"})
reg(ConfigDef{Key: "core.plugin.shm_arena_mb", Default: "4", Type: "int", DisplayName: "插件共享内存(MB)",
Description: "子进程插件共用的共享段 arena 容量(1–256MB)。" +
"段大小在启动时一次确定,**修改后必须重启内核才生效**;" +
"改大不会立刻占物理内存(memfd 惰性分配),但会扩大虚拟地址空间占用。",
Category: "plugin"})
reg(ConfigDef{Key: "core.memory.graph", Default: filepath.Join(dataDir, "memory", "graph.db"), Type: "string", DisplayName: "图数据库路径", Description: "长期记忆(图数据库)存储路径", Category: "paths"})
reg(ConfigDef{Key: "core.memory.text", Default: filepath.Join(dataDir, "memory", "text"), Type: "string", DisplayName: "文本记忆路径", Description: "短期文本记忆存储目录", Category: "paths"})
reg(ConfigDef{Key: "core.memory.documents", Default: filepath.Join(dataDir, "memory", "documents"), Type: "string", DisplayName: "文档记忆路径", Description: "文档记忆存储目录", Category: "paths"})

View File

@ -118,7 +118,7 @@ func (r *Registry) ensureProcHost() (*proc.Host, error) {
if r.procHost != nil {
return r.procHost, nil
}
host, err := proc.NewHost()
host, err := proc.NewHostWithArenaSize(r.arenaBytesFromConfig())
if err != nil {
return nil, err
}
@ -278,3 +278,38 @@ func (r *Registry) ResetProcCrashCount(name string) {
defer r.crashMu.Unlock()
delete(r.procCrashes, name)
}
// arenaBytesFromConfig 读 arena 容量配置(core.plugin.shm_arena_mb)。
//
// ## 为什么容量只能在启动时决定
//
// arena 是统一区域里一段连续 mmap,而 SharedRef 存**绝对偏移**。
// 变大需要 remap,而每个 plugin.bin 子进程可能正在读同一页 ——
// 跨进程 munmap 会让它 SIGSEGV。该路径历史上三次被明确搁置。
// ⇒ 这里只决定"这次启动开多大",改配置必须重启内核才生效。
//
// ## 越界时记日志
//
// clamp 到 1..256 MB。超上限时**说出来**(否则"我配了 512"会静默变成
// "仍是 256",与配置系统其它项的行为不一致)。
func (r *Registry) arenaBytesFromConfig() uint32 {
const defMB = 4
const minMB = 1
const maxMB = 256
mb := defMB
if r.cfgReg != nil {
if v := r.cfgReg.GetInt("core.plugin.shm_arena_mb", defMB); v != 0 {
mb = v
}
}
switch {
case mb < minMB:
log.Printf("[plugin] core.plugin.shm_arena_mb=%d 过小,按 %dMB 使用", mb, minMB)
mb = minMB
case mb > maxMB:
log.Printf("[plugin] core.plugin.shm_arena_mb=%d 超过上限,按 %dMB 使用", mb, maxMB)
mb = maxMB
}
return uint32(mb) * 1024 * 1024
}

View File

@ -98,8 +98,9 @@ const (
// 但它让单个工具调用可以承载 MB 级 payload,不必再退回内联 RPC。
const arenaDefaultCapacity = 4 * 1024 * 1024
// arenaPublishSize 是统一区域里预留给 arena 的字节数。
var arenaPublishSize = uint32(arenaDefaultCapacity)
// 容量由 NewHostWithArenaSize 决定(见 host.go),这里不再维护第二份。
// 之前有个 arenaPublishSize 变量,但自 NewHost 改为接受参数后已无人读取 ——
// 留着一个"看着能改、改了没效果"的开关比没有更危险。
// arenaRegion 是块分配器视图。
//

View File

@ -69,11 +69,33 @@ type Host struct {
// 三者共同点:全部插件看到同一份物理页,段内一律用相对偏移而非指针
// (实验 2 已验证各进程 mmap 到不同虚拟地址时偏移解引用仍正确)。
func NewHost() (*Host, error) {
return NewHostWithArenaSize(arenaDefaultCapacity)
}
// NewHostWithArenaSize 与 NewHost 相同,但按调用方给定的 arena 容量创建段。
//
// ## 为什么容量只能在**构造时**决定
//
// arena 是统一区域里的一段连续 mmap,而 SharedRef 存的是**绝对偏移**。
// 要变大就得 remap,而对端进程(每个 plugin.bin)可能正在读同一页 ——
// munmap + 重新映射会让它 SIGSEGV。跨进程 remap 因此被明确否决
// (历史上三次实现计划均搁置)。⇒ 容量只影响"启动时开多大",
// 改了配置必须重启内核。
//
// ## 越界如何处理
//
// 太小(低于 arenaMinSize)无法建立分配器;这里**返回明确错误**而不是
// 静默回落到默认值 —— 配置写错要能看见,否则"我明明配了 32MB"会变成
// "悄悄仍是 4MB"。调用方(Registry)负责读配置 + clamp。
func NewHostWithArenaSize(arenaBytes uint32) (*Host, error) {
if arenaBytes < arenaMinSize() {
return nil, fmt.Errorf("共享段 arena 容量过小:%d 字节(至少 %d)", arenaBytes, arenaMinSize())
}
// 统一区域大小:SuperBlock + StageContext + EvtRing + Exchange Arena
//
// +8 是 arena 基址 8 字节对齐的 padding 余量:arenaOff 向上取整可能
// 吃掉最多 4 字节,预留 8 字节保证 arenaCap 不会小于 arenaPublishSize。
unifiedSize := superBlockSize + shmDefaultSize + evtTotalSize + int(arenaPublishSize) + 8
// 吃掉最多 4 字节,预留 8 字节保证 arenaCap 不会小于请求容量。
unifiedSize := superBlockSize + shmDefaultSize + evtTotalSize + int(arenaBytes) + 8
memfd, data, err := allocShm(unifiedSize)
if err != nil {
return nil, err

View File

@ -185,3 +185,26 @@ func TestArenaFragmentationPreventsLargeRequest(t *testing.T) {
t.Log("当前实现已能整理碎片;本判据需改为验证整理后的可用性")
}
}
// TestArenaHostHonoursRequestedCapacity:按调用方给定的容量创建共享段,
// 容量必须真的生效(MaxPayload 随容量变化),而不是静默用默认值。
func TestArenaHostHonoursRequestedCapacity(t *testing.T) {
const mb = 8
h, err := proc.NewHostWithArenaSize(mb * 1024 * 1024)
if err != nil {
t.Skipf("共享内存不可用:%v", err)
}
defer func() { _ = h.Close() }()
payload := bytes.Repeat([]byte("z"), mb*1024*1024/2)
ref := putBytes(t, h, proc.OwnerHost, payload)
assertReadable(t, h, ref, payload)
}
// TestArenaHostRejectsTooSmallCapacity:低于分配器最小尺寸时报明确错误,
// 不静默回落到默认容量。
func TestArenaHostRejectsTooSmallCapacity(t *testing.T) {
if _, err := proc.NewHostWithArenaSize(64); err == nil {
t.Fatal("过小的 arena 容量被接受(应报错而非回落默认值)")
}
}