Files
HomeAgent/internal/agent/core/runetrunc_test.go
JianFeeeee d65b742bd7 fix(plugin): 修两处持锁自死锁(CI 双红真因)+ tools_ready 事件声明
## 死锁(这是 CI 连续两次红的真因)

`StopAll` 与 `StopPlugin` 都在持有 `r.mu` **写锁**时调用
`RemoveBuiltinSkill` → `skillDirOf` → `r.mu.RLock()` ⇒ **自死锁**。

后果不是报错而是**卡死**:
- internal/plugins 卡 300s 超时(TestCapability_RestrictedPluginStillLoads)
- internal/plugin  卡 840s 超时(TestStopPluginDoesNotTouchPersistentState)
- CI 的 `go test ./...` 与 `go test -race` 双双红

修法:拆出**不加锁**的内部版本,持锁路径只走它:
- `skillDirFrom(dataDir, name)`(纯函数)
- `removeBuiltinSkillLocked(pluginName, p, dataDir)`(显式传 dataDir,
  避免"看起来没取锁、一改就取锁"的隐患)
- `announceSkillRemovedLocked`(持锁下直读 r.evBus)

修后:internal/plugins 300s → 12s,internal/plugin 840s → 0.7s。

## 防回归判据

`TestStopAllAndStopPluginDoNotDeadlock`:死锁**不返回**,普通断言抓不到,
所以用**超时**当判据(5 秒未返回即失败),并指明应改用 Locked 版本。

## tools_ready 事件声明

新增的 tools_ready 触发 `TestEveryPublishedEventHasAnOutwardPath`
(该判据防"事件发了、客户端收不到"的静默失效)。

tools_ready 是**内核内部**就绪信号(plugin-host → seq 等订阅者),
不是对话流程事件,前端无需感知 ⇒ 在 allowed 里显式声明理由。

## 验证

go build ./... / go vet ./... / go test -p 1 ./... / -race 全通过。
2026-10-25 18:25:37 +08:00

92 lines
3.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package core
// 全仓守卫:按字节截断字符串是中文环境下的**必然 bug**。
//
// 中文占 3 字节,`s[:N]` 的切点常落在多字节字符中间 ⇒ 产生非法 UTF-8。
// 实测代价(不是理论风险):
//
// skillmgr: desc[:80] 把 25 个 skill 里 8 个(32%)的索引行切坏,
// 模型看到 "…以后一次<E4B880>",且"什么时候该用"整段丢失
// ⇒ e2e 实测该用 seq 的场景,模型改去调别的工具
// tooldefs: desc[:60] 同样问题
// knowledge/distill: preview[:200] / raw[:400] 同样问题
//
// 正确写法:先转 []rune 再切。本判据扫描源码,发现新的字节截断即失败。
import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
)
// byteSliceRe 匹配 `ident[:数字]` 形式(字节切片的典型写法)。
var byteSliceRe = regexp.MustCompile(`([a-zA-Z_][a-zA-Z0-9_.]*)\[:\s*(\d+)\]`)
// byteSliceAllow 是**已知安全**的例外:切的是 ASCII 生成物或切片重置。
var byteSliceAllow = map[string]bool{
// 文件路径/哈希/hex 全是 ASCII
"fp": true, "h": true, "id": true, "d": true, "digest": true,
"sum": true, "shortFP": true, "head": true, "monthKey": true,
// `x[:0]` 是"清空切片并复用底层数组"的惯用法,不涉及字符
"msgs": true, "args": true, "queue": true, "pending": true,
"Children": true, "changeSets": true, "keep": true, "out": true,
"b": true, "buf": true, "data": true,
// st.Names 是 []string(工具结果的名称**列表**),切它是切片截断,
// 不涉及字符编码。误报清单要写明依据,否则以后没人敢再动这里。
"show": true,
}
func TestNoByteLevelStringTruncation(t *testing.T) {
root := ".."
var offenders []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
src, rerr := os.ReadFile(path)
if rerr != nil {
return nil
}
for i, line := range strings.Split(string(src), "\n") {
trimmed := strings.TrimSpace(line)
// 跳过注释(我们的说明里会引用这个模式本身)
if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "*") {
continue
}
for _, m := range byteSliceRe.FindAllStringSubmatch(line, -1) {
ident, n := m[1], m[2]
// `[:0]` 是切片重置,不是字符截断
if n == "0" {
continue
}
if byteSliceAllow[ident] {
continue
}
// 形如 `string(r[:60])` 且 r 来自 []rune 的写法:变量名含 rune 约定
if ident == "r" {
continue
}
offenders = append(offenders, filepath.Join(path)+":"+strconv.Itoa(i+1)+" "+trimmed)
}
}
return nil
})
if err != nil {
t.Fatalf("walk: %v", err)
}
if len(offenders) > 0 {
t.Fatalf("发现 %d 处疑似**按字节**截断字符串(中文环境下会产生非法 UTF-8):\n %s\n\n"+
"正确写法:先转 []rune 再切,例如 r := []rune(s); s = string(r[:N]) + \"...\"\n"+
"若确实是 ASCII 生成物(hash/路径)或切片重置(x[:0]),加进 byteSliceAllow 并注明理由。",
len(offenders), strings.Join(offenders, "\n "))
}
}