新增 plugins/tools/io/file_targets.go:fileTool 与 cropTool 实现 tool.FileTargeter (编译期钉住 var _ tool.FileTargeter,声明集中一处可审计)。 file:read/list/search ⇒ 读;其余(含 delete / 未知 / 缺失 action)⇒ 写(保守侧,宁可多挡一次)。 路径取沙箱解析前的声明值(绝对、clean、已确认在 roots 内,纯词法不触盘);沙箱会拒的路径不声明。 search 的缺省 base 与执行路径共用 f.searchBase(避免声明目录 ≠ 实际走查目录)。 image-crop:仅本地路径声明为读(URL/data URI 不落盘);分类与 load 共用一份。 体检清单:file ✅ / image-crop ✅ / document-read ❌(sidecar 走「临时文件 + rename」原子替换, 无撕读风险;且布局属未导出的第二份真相)/ basic、http、knowledge 等不碰盘; vision·ocr(internal 越界)、terminal(cmd 越界 + 目标不可知)、MCP 外部工具 未做 —— 如实登记为遗留。 测试:单元(action→Op 全表 / 路径归一 / 不声明场景)+ 机制层 6 条(真插件 + 真 Dispatcher + 真占用表, 用闸门工具把并发变确定性事件,不靠 sleep)。 变异 5 条(一切按写 / 未知按读 / 用原始相对串 / crop 不声明 / file 声明被抹掉)。 注:本切片声明正确但生效依赖 ef3dcb6(网关给每条在途路挂互斥身份)。
209 lines
9.0 KiB
Go
209 lines
9.0 KiB
Go
package io
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
)
|
||
|
||
// 本文件是**文件级互斥的机制级回归**(声明面单元见 file_targets_test.go,夹具见
|
||
// file_claims_rig_test.go):把**真实插件**注册进真实的 tool.Dispatcher,用可控闸门把
|
||
// 「一路持有占用」变成确定性事件(占用登记在 Execute 之前、释放在其后,因此停在闸门里就
|
||
// 等于持有),逐条钉住主人口径:
|
||
//
|
||
// - 同一文件的两个读**并行放行**(检索类并行不该被互斥拖住);
|
||
// - 读与写、写与写**判忙**,且回灌给模型的文案带文件名与文件状态;
|
||
// - 不同文件**放行**;
|
||
// - 未实现 FileTargeter 的工具**完全不受影响**(占用表 0 键)。
|
||
//
|
||
// 这一层走的是派发器真实的登记 / 释放路径与真实的进程内占用表 —— 声明面一错就红。
|
||
|
||
// TestFileClaims_TwoReadsOfTheSameFileRunParallel 钉住①:同一文件的两个读并行放行
|
||
// (主人「优先考虑性能而不是预算」的口径)。
|
||
func TestFileClaims_TwoReadsOfTheSameFileRunParallel(t *testing.T) {
|
||
t.Parallel()
|
||
rig := newClaimRig(t)
|
||
file := rig.seed(t, "notes.txt", "hello from disk")
|
||
holder := rig.gated(t, "file", "file-read-holder")
|
||
first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "read", "path": file})
|
||
awaitEnter(t, holder.entered)
|
||
|
||
val, err := invokeLeg(rig.d, "turn-b", "file", map[string]any{"action": "read", "path": file})
|
||
if err != nil {
|
||
t.Fatalf("同一文件的两个读必须允许并行,得到 %v", err)
|
||
}
|
||
if content := toolContent(t, val); content != "hello from disk" {
|
||
t.Fatalf("第二路读到的正文 = %q", content)
|
||
}
|
||
// 两路登记的是**同一个键**(同类别、同路径):这才叫「同一个文件的读读并行」,
|
||
// 而不是两路各自登记了不同的键。
|
||
targets := rig.rec.targets()
|
||
if len(targets) != 2 || targets[0] != targets[1] {
|
||
t.Fatalf("两路读应登记同一个目标键,得到 %#v", targets)
|
||
}
|
||
if got := rig.rec.liveCount(); got != 1 {
|
||
t.Fatalf("第二路退场后应只剩持锁那一路的键,实际 %d 个", got)
|
||
}
|
||
|
||
holder.release()
|
||
if res := awaitDone(t, first); res.err != nil {
|
||
t.Fatalf("持锁那一路应正常完成: %v", res.err)
|
||
}
|
||
assertNoClaims(t, rig.rec, 2)
|
||
}
|
||
|
||
// TestFileClaims_ReadIsBusyWhileAnotherLegWrites 钉住②:读与写同一文件 ⇒ 判忙,且面向
|
||
// 模型的文案带文件名与文件状态(谁在写、本次读不了);持锁那一路退场后立刻可再进。
|
||
func TestFileClaims_ReadIsBusyWhileAnotherLegWrites(t *testing.T) {
|
||
t.Parallel()
|
||
rig := newClaimRig(t)
|
||
file := rig.seed(t, "notes.txt", "v1")
|
||
holder := rig.gated(t, "file", "file-write-holder")
|
||
first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "write", "path": file, "content": "v2"})
|
||
awaitEnter(t, holder.entered)
|
||
|
||
_, err := invokeLeg(rig.d, "turn-b", "file", map[string]any{"action": "read", "path": file})
|
||
assertBusy(t, err, "notes.txt", "正在被另一路写入", "本次读取无法进行")
|
||
|
||
holder.release()
|
||
if res := awaitDone(t, first); res.err != nil {
|
||
t.Fatalf("持锁那一路应正常完成: %v", res.err)
|
||
}
|
||
if data, err := os.ReadFile(file); err != nil || string(data) != "v2" {
|
||
t.Fatalf("持锁那一路的写入没有落盘: %q, %v", data, err)
|
||
}
|
||
if _, err := invokeLeg(rig.d, "turn-c", "file", map[string]any{"action": "read", "path": file}); err != nil {
|
||
t.Fatalf("前一路退场后必须已释放占用,得到 %v", err)
|
||
}
|
||
// 三次尝试:持锁那一路的写、被判忙的那次读、退场后的第二次读。
|
||
assertNoClaims(t, rig.rec, 3)
|
||
}
|
||
|
||
// TestFileClaims_WriteVsWriteIsBusy 钉住②(写写):第二路写同一文件被判忙、**根本没有执行**
|
||
// (闸门未被进入),文件里留下的是持锁那一路的内容。
|
||
func TestFileClaims_WriteVsWriteIsBusy(t *testing.T) {
|
||
t.Parallel()
|
||
rig := newClaimRig(t)
|
||
file := rig.seed(t, "notes.txt", "seed")
|
||
holder := rig.gated(t, "file", "file-write-holder")
|
||
peer := rig.gated(t, "file", "file-write-peer")
|
||
first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "write", "path": file, "content": "held"})
|
||
awaitEnter(t, holder.entered)
|
||
|
||
_, err := invokeLeg(rig.d, "turn-b", peer.Name(), map[string]any{"action": "write", "path": file, "content": "peer"})
|
||
assertBusy(t, err, "notes.txt", "正在被另一路写入", "本次写入无法进行")
|
||
select {
|
||
case <-peer.entered:
|
||
t.Fatal("被判忙的调用绝不该执行")
|
||
default:
|
||
}
|
||
|
||
holder.release()
|
||
if res := awaitDone(t, first); res.err != nil {
|
||
t.Fatalf("持锁那一路应正常完成: %v", res.err)
|
||
}
|
||
if data, err := os.ReadFile(file); err != nil || string(data) != "held" {
|
||
t.Fatalf("文件内容应只剩持锁那一路写的 held: %q, %v", data, err)
|
||
}
|
||
// 两次尝试:持锁那一路的写,以及第二路被判忙的写(判忙那一路也问过表,只是没占到键)。
|
||
assertNoClaims(t, rig.rec, 2)
|
||
}
|
||
|
||
// TestFileClaims_CrossToolReadOfAWrittenFileIsBusy 钉住②(跨工具):file 正在写一个文件时,
|
||
// image-crop 读**同一个文件**同样判忙 —— 这正是文件级互斥要盖住的场景(读到的会是撕开的
|
||
// 内容),也是「同一份路径口径被两个工具共用」的证据。
|
||
func TestFileClaims_CrossToolReadOfAWrittenFileIsBusy(t *testing.T) {
|
||
t.Parallel()
|
||
rig := newClaimRig(t)
|
||
file := rig.seed(t, "notes.txt", "v1")
|
||
holder := rig.gated(t, "file", "file-write-holder")
|
||
first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "write", "path": file, "content": "v2"})
|
||
awaitEnter(t, holder.entered)
|
||
|
||
_, err := invokeLeg(rig.d, "turn-b", "image-crop", map[string]any{
|
||
"image": file, "x": 0, "y": 0, "width": 1, "height": 1,
|
||
})
|
||
assertBusy(t, err, "notes.txt", "正在被另一路写入", "本次读取无法进行")
|
||
|
||
holder.release()
|
||
if res := awaitDone(t, first); res.err != nil {
|
||
t.Fatalf("持锁那一路应正常完成: %v", res.err)
|
||
}
|
||
// 两次尝试:持锁那一路的写,以及 image-crop 读同一文件被判忙的那次。
|
||
assertNoClaims(t, rig.rec, 2)
|
||
}
|
||
|
||
// TestFileClaims_DifferentFilesRunParallel 钉住③:不是同一个文件 ⇒ 放行(且真的写成功),
|
||
// 占用表里两路各占一个键、退场后清空。
|
||
func TestFileClaims_DifferentFilesRunParallel(t *testing.T) {
|
||
t.Parallel()
|
||
rig := newClaimRig(t)
|
||
held := rig.seed(t, "a.txt", "a")
|
||
other := filepath.Join(rig.dir, "b.txt")
|
||
holder := rig.gated(t, "file", "file-write-holder")
|
||
first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "write", "path": held, "content": "held"})
|
||
awaitEnter(t, holder.entered)
|
||
|
||
val, err := invokeLeg(rig.d, "turn-b", "file", map[string]any{"action": "write", "path": other, "content": "bee"})
|
||
if err != nil {
|
||
t.Fatalf("不同文件必须允许并行操作,得到 %v", err)
|
||
}
|
||
if m, ok := val.(map[string]any); !ok || m["size"] != len("bee") {
|
||
t.Fatalf("第二路的结果异常: %#v", val)
|
||
}
|
||
if data, err := os.ReadFile(other); err != nil || string(data) != "bee" {
|
||
t.Fatalf("第二路的写入没有落盘: %q, %v", data, err)
|
||
}
|
||
if got := rig.rec.liveCount(); got != 1 {
|
||
t.Fatalf("第二路退场后应只剩持锁那一路的键,实际 %d 个", got)
|
||
}
|
||
|
||
holder.release()
|
||
if res := awaitDone(t, first); res.err != nil {
|
||
t.Fatalf("持锁那一路应正常完成: %v", res.err)
|
||
}
|
||
assertNoClaims(t, rig.rec, 2)
|
||
}
|
||
|
||
// TestFileClaims_ToolWithoutDeclarationIsUnaffected 钉住④:未实现 FileTargeter 的工具完全
|
||
// 不受影响 —— (a) 它一次都不碰占用表;(b) 真实插件里不碰文件的 web-search 在别的路持有
|
||
// 写占用时照常调用,不判忙、不登记。
|
||
func TestFileClaims_ToolWithoutDeclarationIsUnaffected(t *testing.T) {
|
||
t.Parallel()
|
||
rig := newClaimRig(t)
|
||
|
||
// (a) 两路并发调用一个只实现 sdk.Tool 的工具:都成功,占用表 0 键、登记 0 次。
|
||
plain := &plainTool{name: "plain-tool"}
|
||
if err := rig.d.Register(plain); err != nil {
|
||
t.Fatalf("Register %q: %v", plain.Name(), err)
|
||
}
|
||
for _, leg := range []string{"turn-a", "turn-b"} {
|
||
val, err := invokeLeg(rig.d, leg, plain.Name(), map[string]any{"echo": "hi"})
|
||
if err != nil {
|
||
t.Fatalf("未声明目标的工具不该受影响(leg=%s): %v", leg, err)
|
||
}
|
||
if echo, _ := val.(string); echo != "hi" {
|
||
t.Fatalf("结果 = %#v", val)
|
||
}
|
||
}
|
||
assertNoClaims(t, rig.rec, 0)
|
||
|
||
// (b) 另一路持有某个文件的写占用时,未声明目标的真实工具照常可调用。
|
||
file := rig.seed(t, "notes.txt", "v1")
|
||
holder := rig.gated(t, "file", "file-write-holder")
|
||
first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "write", "path": file, "content": "v2"})
|
||
awaitEnter(t, holder.entered)
|
||
|
||
_, err := invokeLeg(rig.d, "turn-b", "web-search", map[string]any{"query": "x"})
|
||
assertNotBusy(t, err)
|
||
if got := rig.rec.claimAttempts(); got != 1 {
|
||
t.Fatalf("只有持锁那一路该问占用表,实际问了 %d 次", got)
|
||
}
|
||
|
||
holder.release()
|
||
if res := awaitDone(t, first); res.err != nil {
|
||
t.Fatalf("持锁那一路应正常完成: %v", res.err)
|
||
}
|
||
assertNoClaims(t, rig.rec, 1)
|
||
}
|