package tool import ( "context" "errors" "path/filepath" "runtime" "strings" "testing" "time" ) // 本文件钉住文件级互斥(design/15 §2.5.7、主人 2026-10-05 裁决 3)的**占用表 // 语义**: // - 同一文件被**另一路**占住 ⇒ 立刻判忙(文案带文件状态,可被 LLM 消化), // **绝不阻塞等待**(不排队死等); // - 不是同一个文件 ⇒ 并行放行; // - 同一文件的两个**读** ⇒ 并行放行(结论见 TestFileClaimsTwoReadsRunParallel); // - 同一路重复占用幂等、且不会被自己挡住(先读后写同一文件); // - 释放后另一路立刻可接手;占用表随路收尾清空(有界); // - **无路标识 ⇒ 不登记也不检查**(单路 / 既有路径逐字不变)。 // claimProbeTimeout 是「判忙必须立刻返回」的上界。它只把「阻塞等待」这种错误 // 实现暴露成失败,不参与任何时序竞态(占用判定本身是同步的)。 const claimProbeTimeout = 5 * time.Second // newTestFileClaims 返回占用表的具体实现,供白盒断言「表是否有界」。 func newTestFileClaims() *fileClaims { return NewFileClaimer().(*fileClaims) } // writeTarget / readTarget 按占用键的正式拼法造目标(与 Dispatcher 侧同源)。 func writeTarget(path string) string { return fileClaimKey(FileOpWrite, path) } func readTarget(path string) string { return fileClaimKey(FileOpRead, path) } // TestFileClaimsSameFileOtherLegBusy 钉住主人口径的核心:同一文件正被另一路 // 操作 ⇒ 明确判忙(errors.Is(ErrFileBusy))、**不阻塞等待**、文案里带出文件状态 // 与路径,使模型能自己决定「等一会再操作」。 func TestFileClaimsSameFileOtherLegBusy(t *testing.T) { t.Parallel() c := newTestFileClaims() ctx := context.Background() file := filepath.Join(t.TempDir(), "notes.txt") if err := c.Claim(ctx, "turn-a", writeTarget(file)); err != nil { t.Fatalf("第一路占用失败: %v", err) } done := make(chan error, 1) go func() { done <- c.Claim(ctx, "turn-b", writeTarget(file)) }() select { case err := <-done: if !errors.Is(err, ErrFileBusy) { t.Fatalf("同一文件被另一路占用必须判忙,得到 %v", err) } msg := err.Error() for _, want := range []string{"请稍后再试", "正在被另一路写入", "notes.txt"} { if !strings.Contains(msg, want) { t.Fatalf("回灌给模型的文案缺少 %q: %s", want, msg) } } case <-time.After(claimProbeTimeout): t.Fatal("第二路在占用表上阻塞等待了 —— 主人口径是不排队死等,判忙即返回") } // 判忙不改变占用归属:第三路同样判忙,第一路仍然持锁。 if err := c.Claim(ctx, "turn-c", writeTarget(file)); !errors.Is(err, ErrFileBusy) { t.Fatalf("第三路也应判忙,得到 %v", err) } } // TestFileClaimsDifferentFilesRunParallel 钉住「不是同一个文件 ⇒ 允许并行」。 func TestFileClaimsDifferentFilesRunParallel(t *testing.T) { t.Parallel() c := newTestFileClaims() ctx := context.Background() dir := t.TempDir() if err := c.Claim(ctx, "turn-a", writeTarget(filepath.Join(dir, "a.txt"))); err != nil { t.Fatalf("A 占用 a.txt: %v", err) } if err := c.Claim(ctx, "turn-b", writeTarget(filepath.Join(dir, "b.txt"))); err != nil { t.Fatalf("不同文件必须允许并行操作,得到 %v", err) } // 同一目录下的另一个文件同样放行(互斥粒度是文件,不是目录)。 if err := c.Claim(ctx, "turn-c", writeTarget(filepath.Join(dir, "sub", "c.txt"))); err != nil { t.Fatalf("不同文件必须允许并行操作,得到 %v", err) } } // TestFileClaimsTwoReadsRunParallel 钉住第 4 条的结论:同一文件的**两个读允许 // 并行**(读不改变内容,没有数据风险;主人「优先考虑性能」口径下检索类并行不该 // 被互斥拖住)。而读与写之间仍然互斥 —— 读到「正在被写」的一半内容与两路同时 // 写属同一类数据风险。 func TestFileClaimsTwoReadsRunParallel(t *testing.T) { t.Parallel() c := newTestFileClaims() ctx := context.Background() file := filepath.Join(t.TempDir(), "notes.txt") if err := c.Claim(ctx, "turn-a", readTarget(file)); err != nil { t.Fatalf("第一路读: %v", err) } if err := c.Claim(ctx, "turn-b", readTarget(file)); err != nil { t.Fatalf("同一文件的两个读必须允许并行,得到 %v", err) } if err := c.Claim(ctx, "turn-c", writeTarget(file)); !errors.Is(err, ErrFileBusy) { t.Fatalf("有读在途时写入必须判忙,得到 %v", err) } if !strings.Contains(c.Claim(ctx, "turn-c", writeTarget(file)).Error(), "正在被另一路读取") { t.Fatal("写入被判忙时,文案必须说清是「另一路正在读取」") } } // TestFileClaimsWriteThenReadBusy 钉住相反方向:写在途时读同一文件判忙, // 且文案说清是「另一路正在写入」。 func TestFileClaimsWriteThenReadBusy(t *testing.T) { t.Parallel() c := newTestFileClaims() ctx := context.Background() file := filepath.Join(t.TempDir(), "notes.txt") if err := c.Claim(ctx, "turn-a", writeTarget(file)); err != nil { t.Fatalf("第一路写: %v", err) } err := c.Claim(ctx, "turn-b", readTarget(file)) if !errors.Is(err, ErrFileBusy) { t.Fatalf("写在途时读同一文件必须判忙,得到 %v", err) } if !strings.Contains(err.Error(), "正在被另一路写入") { t.Fatalf("文案必须说清是「另一路正在写入」: %s", err) } } // TestFileClaimsSameLegIsIdempotent 钉住同一路的口径:重复占用同一目标幂等, // 且**一路自己先读后写同一文件不会被自己挡住**(否则一条路自己的多步工具调用 // 会互相锁死)。同时别的路仍被正确挡住。 func TestFileClaimsSameLegIsIdempotent(t *testing.T) { t.Parallel() c := newTestFileClaims() ctx := context.Background() file := filepath.Join(t.TempDir(), "notes.txt") if err := c.Claim(ctx, "turn-a", readTarget(file)); err != nil { t.Fatalf("自己读: %v", err) } if err := c.Claim(ctx, "turn-a", readTarget(file)); err != nil { t.Fatalf("同一路重复占用必须是幂等的: %v", err) } if err := c.Claim(ctx, "turn-a", writeTarget(file)); err != nil { t.Fatalf("同一路先读后写同一文件不该被自己挡住: %v", err) } if err := c.Claim(ctx, "turn-a", readTarget(file)); err != nil { t.Fatalf("同一路读写并存必须是幂等的: %v", err) } if err := c.Claim(ctx, "turn-b", readTarget(file)); !errors.Is(err, ErrFileBusy) { t.Fatalf("别的路仍必须被挡住,得到 %v", err) } if err := c.Claim(ctx, "turn-b", writeTarget(file)); !errors.Is(err, ErrFileBusy) { t.Fatalf("别的路仍必须被挡住,得到 %v", err) } } // TestFileClaimsReleaseLetsOtherLegTakeOver 钉住释放:立刻可接手、非占用者释放 // 是空操作、最后一个占用者离开后占用表清空(有界 —— 绝不随会话长度增长)。 func TestFileClaimsReleaseLetsOtherLegTakeOver(t *testing.T) { t.Parallel() c := newTestFileClaims() ctx := context.Background() file := filepath.Join(t.TempDir(), "notes.txt") if err := c.Claim(ctx, "turn-a", writeTarget(file)); err != nil { t.Fatalf("A 占用: %v", err) } c.Release(ctx, "turn-a", writeTarget(file)) if err := c.Claim(ctx, "turn-b", writeTarget(file)); err != nil { t.Fatalf("释放后另一路应立即接手,得到 %v", err) } // 非占用者释放是空操作:B 的占用不受影响。 c.Release(ctx, "turn-z", writeTarget(file)) if err := c.Claim(ctx, "turn-c", writeTarget(file)); !errors.Is(err, ErrFileBusy) { t.Fatalf("B 的占用被非占用者的释放动掉了,得到 %v", err) } c.Release(ctx, "turn-b", writeTarget(file)) if got := len(c.held); got != 0 { t.Fatalf("占用表残留 %d 个键,必须随路收尾清空(有界)", got) } // 读一侧的释放同样要摘干净(读与写同属一个键)。 if err := c.Claim(ctx, "turn-a", readTarget(file)); err != nil { t.Fatalf("A 读: %v", err) } c.Release(ctx, "turn-a", writeTarget(file)) if got := len(c.held); got != 0 { t.Fatalf("释放必须同时摘掉读侧占用,残留 %d 个键", got) } } // TestFileClaimsEmptyLegIsNoOp 钉住兼容硬要求:**无路标识 ⇒ 不登记也不检查**。 // 空路(单路 / 子代理 / 未接线)既不会被挡住,也不会在占用表里留下任何痕迹。 func TestFileClaimsEmptyLegIsNoOp(t *testing.T) { t.Parallel() c := newTestFileClaims() ctx := context.Background() file := filepath.Join(t.TempDir(), "notes.txt") if err := c.Claim(ctx, "", writeTarget(file)); err != nil { t.Fatalf("空路标识不做互斥,得到 %v", err) } if err := c.Claim(ctx, "turn-a", writeTarget(file)); err != nil { t.Fatalf("空路标识不该登记占用,真路必须能正常占用: %v", err) } if err := c.Claim(ctx, "turn-b", writeTarget(file)); !errors.Is(err, ErrFileBusy) { t.Fatalf("真路之间仍然要互斥,得到 %v", err) } // 真路占住时,空路标识照样放行(完全不做互斥)—— 这正是「单路逐字不变」。 if err := c.Claim(ctx, "", writeTarget(file)); err != nil { t.Fatalf("空路标识不做互斥,得到 %v", err) } // 空目标、nil ctx 也不 panic。 if err := c.Claim(ctx, "turn-a", ""); err != nil { t.Fatalf("空目标不做互斥,得到 %v", err) } if err := c.Claim(nil, "turn-x", writeTarget(file)); !errors.Is(err, ErrFileBusy) { t.Fatalf("nil ctx 不该改变判定,得到 %v", err) } c.Release(ctx, "", writeTarget(file)) if err := c.Claim(ctx, "turn-c", writeTarget(file)); !errors.Is(err, ErrFileBusy) { t.Fatalf("空路标识的释放必须是空操作,得到 %v", err) } } // TestFileClaimsPathAliasesCollapse 钉住占用键的**词法规范化**:同一文件的 // `sub/../notes.txt` 与 `./notes.txt` 写法必须撞成同一个键,否则互斥形同虚设。 // Windows 上还要折叠大小写(`NOTES.TXT` 与 `notes.txt` 是同一个文件)。 func TestFileClaimsPathAliasesCollapse(t *testing.T) { t.Parallel() c := newTestFileClaims() ctx := context.Background() base := t.TempDir() file := filepath.Join(base, "sub", "..", "notes.txt") alias := filepath.Join(base, ".", "notes.txt") if err := c.Claim(ctx, "turn-a", writeTarget(file)); err != nil { t.Fatalf("A 占用: %v", err) } if err := c.Claim(ctx, "turn-b", writeTarget(alias)); !errors.Is(err, ErrFileBusy) { t.Fatalf("同一文件的两种写法必须撞成同一个键,得到 %v", err) } if runtime.GOOS == "windows" { upper := strings.ToUpper(alias) if err := c.Claim(ctx, "turn-b", writeTarget(upper)); !errors.Is(err, ErrFileBusy) { t.Fatalf("Windows 大小写不敏感,必须撞成同一个键,得到 %v", err) } } } // TestFileClaimKeyShape 钉住占用键的形状与「未知即写」的保守方向:裸路径 // (外部实现方直接传路径的口径)按**写**处理,与读写都互斥。 func TestFileClaimKeyShape(t *testing.T) { t.Parallel() if op, path := parseFileClaimKey(fileClaimKey(FileOpRead, "a.txt")); op != FileOpRead || path != "a.txt" { t.Fatalf("读键往返 = (%q,%q),want (read,a.txt)", op, path) } if op, path := parseFileClaimKey(fileClaimKey(FileOpWrite, "a.txt")); op != FileOpWrite || path != "a.txt" { t.Fatalf("写键往返 = (%q,%q),want (write,a.txt)", op, path) } // 空/未知类别与裸路径一律按写(宁可多挡一次,也不放过并发写)。 for _, target := range []string{"a.txt", fileClaimKey("", "a.txt"), fileClaimKey("chmod", "a.txt")} { if op, path := parseFileClaimKey(target); op != FileOpWrite || path != "a.txt" { t.Fatalf("目标 %q 应保守按写处理,得到 (%q,%q)", target, op, path) } } } // TestFileClaimsBareTargetBlocksReadAndWrite 把上一条的「未知即写」钉到行为上: // 裸路径占用必须同时挡住读与写。 func TestFileClaimsBareTargetBlocksReadAndWrite(t *testing.T) { t.Parallel() c := newTestFileClaims() ctx := context.Background() file := filepath.Join(t.TempDir(), "notes.txt") if err := c.Claim(ctx, "turn-a", file); err != nil { t.Fatalf("裸路径占用: %v", err) } if err := c.Claim(ctx, "turn-b", readTarget(file)); !errors.Is(err, ErrFileBusy) { t.Fatalf("裸路径按写处理 ⇒ 必须挡住读,得到 %v", err) } if err := c.Claim(ctx, "turn-b", writeTarget(file)); !errors.Is(err, ErrFileBusy) { t.Fatalf("裸路径按写处理 ⇒ 必须挡住写,得到 %v", err) } }