Skip to content

Commit 5966520

Browse files
authored
Merge pull request #29 from Gui-Yue/fix_fs_tool_bugs
fix(core): resolve FilePool race condition in ensureWatch
2 parents 8c3b86d + f33b861 commit 5966520

3 files changed

Lines changed: 84 additions & 7 deletions

File tree

src/core/agent.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1340,12 +1340,8 @@ export class Agent {
13401340
};
13411341
outcome = await this.hooks.runPostToolUse(outcome, context);
13421342

1343-
if (toolUse.name === 'fs_read' && toolUse.input?.path) {
1344-
await this.filePool.recordRead(toolUse.input.path);
1345-
}
1346-
if ((toolUse.name === 'fs_write' || toolUse.name === 'fs_edit' || toolUse.name === 'fs_multi_edit') && toolUse.input?.path) {
1347-
await this.filePool.recordEdit(toolUse.input.path);
1348-
}
1343+
// NOTE: recordRead/recordEdit 已在各工具内部调用,此处不再重复调用
1344+
// 参考: fs_read/index.ts:25, fs_write/index.ts:26, fs_edit/index.ts:29,53, fs_multi_edit/index.ts:60,92
13491345

13501346
const success = outcome.ok !== false;
13511347
const duration = Date.now() - (this.toolRecords.get(record.id)?.startedAt ?? Date.now());

src/core/file-pool.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ interface FilePoolOptions {
2424
export class FilePool {
2525
private records = new Map<string, FileRecord>();
2626
private watchers = new Map<string, string>();
27+
private watchPending = new Map<string, Promise<void>>(); // per-path 锁,防止并发创建 watcher
2728
private readonly watchEnabled: boolean;
2829
private readonly onChange?: (event: { path: string; mtime: number }) => void;
2930

@@ -106,10 +107,34 @@ export class FilePool {
106107
return Array.from(this.records.keys());
107108
}
108109

109-
private async ensureWatch(path: string) {
110+
private async ensureWatch(path: string): Promise<void> {
110111
if (!this.watchEnabled) return;
111112
if (!this.sandbox.watchFiles) return;
112113
if (this.watchers.has(path)) return;
114+
115+
// 检查是否有正在进行的 watch 操作(per-path 锁)
116+
const pending = this.watchPending.get(path);
117+
if (pending) {
118+
await pending; // 等待已有操作完成
119+
return;
120+
}
121+
122+
// 创建 watch 操作并存储 Promise
123+
const watchPromise = this.doWatch(path);
124+
this.watchPending.set(path, watchPromise);
125+
126+
try {
127+
await watchPromise;
128+
} finally {
129+
this.watchPending.delete(path);
130+
}
131+
}
132+
133+
private async doWatch(path: string): Promise<void> {
134+
// 再次检查(可能在等待期间已被设置)
135+
if (this.watchers.has(path)) return;
136+
if (!this.sandbox.watchFiles) return;
137+
113138
try {
114139
const id = await this.sandbox.watchFiles([path], (event) => {
115140
const record = this.records.get(path);

tests/unit/core/file-pool.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,62 @@ runner
4747

4848
const status = await pool.checkFreshness('missing.txt');
4949
expect.toEqual(status.isFresh, false);
50+
})
51+
52+
.test('并发 recordEdit 不会创建重复 watcher', async () => {
53+
const dir = createTempDir('concurrent');
54+
const filePath = path.join(dir, 'test.txt');
55+
fs.writeFileSync(filePath, 'content');
56+
57+
const sandbox = new LocalSandbox({ workDir: dir, enforceBoundary: true, watchFiles: false });
58+
59+
// 追踪 watchFiles 调用次数
60+
const watchCalls: string[] = [];
61+
(sandbox as any).watchFiles = async (paths: string[]) => {
62+
watchCalls.push(paths[0]);
63+
await new Promise(r => setTimeout(r, 50)); // 模拟异步延迟
64+
return `watch-${watchCalls.length}`;
65+
};
66+
67+
const pool = new FilePool(sandbox, { watch: true });
68+
69+
// 并发调用 3 次 recordEdit
70+
await Promise.all([
71+
pool.recordEdit('test.txt'),
72+
pool.recordEdit('test.txt'),
73+
pool.recordEdit('test.txt'),
74+
]);
75+
76+
// 验证只创建了 1 个 watcher(per-path 锁生效)
77+
expect.toEqual(watchCalls.length, 1);
78+
})
79+
80+
.test('不同文件的并发 recordEdit 各自创建 watcher', async () => {
81+
const dir = createTempDir('concurrent-multi');
82+
fs.writeFileSync(path.join(dir, 'a.txt'), 'a');
83+
fs.writeFileSync(path.join(dir, 'b.txt'), 'b');
84+
fs.writeFileSync(path.join(dir, 'c.txt'), 'c');
85+
86+
const sandbox = new LocalSandbox({ workDir: dir, enforceBoundary: true, watchFiles: false });
87+
88+
const watchCalls: string[] = [];
89+
(sandbox as any).watchFiles = async (paths: string[]) => {
90+
watchCalls.push(paths[0]);
91+
await new Promise(r => setTimeout(r, 30));
92+
return `watch-${watchCalls.length}`;
93+
};
94+
95+
const pool = new FilePool(sandbox, { watch: true });
96+
97+
// 并发操作 3 个不同文件
98+
await Promise.all([
99+
pool.recordEdit('a.txt'),
100+
pool.recordEdit('b.txt'),
101+
pool.recordEdit('c.txt'),
102+
]);
103+
104+
// 每个文件各创建 1 个 watcher
105+
expect.toEqual(watchCalls.length, 3);
50106
});
51107

52108
export async function run() {

0 commit comments

Comments
 (0)