Просмотр исходного кода

fix(edit): add per-file lock to prevent read-before-write race (#4388)

Spoon 2 месяцев назад
Родитель
Сommit
5cf126d489
2 измененных файлов с 30 добавлено и 4 удалено
  1. 26 0
      packages/opencode/src/file/time.ts
  2. 4 4
      packages/opencode/src/tool/edit.ts

+ 26 - 0
packages/opencode/src/file/time.ts

@@ -3,14 +3,20 @@ import { Log } from "../util/log"
 
 export namespace FileTime {
   const log = Log.create({ service: "file.time" })
+  // Per-session read times plus per-file write locks.
+  // All tools that overwrite existing files should run their
+  // assert/read/write/update sequence inside withLock(filepath, ...)
+  // so concurrent writes to the same file are serialized.
   export const state = Instance.state(() => {
     const read: {
       [sessionID: string]: {
         [path: string]: Date | undefined
       }
     } = {}
+    const locks = new Map<string, Promise<void>>()
     return {
       read,
+      locks,
     }
   })
 
@@ -25,6 +31,26 @@ export namespace FileTime {
     return state().read[sessionID]?.[file]
   }
 
+  export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
+    const current = state()
+    const currentLock = current.locks.get(filepath) ?? Promise.resolve()
+    let release: () => void = () => {}
+    const nextLock = new Promise<void>((resolve) => {
+      release = resolve
+    })
+    const chained = currentLock.then(() => nextLock)
+    current.locks.set(filepath, chained)
+    await currentLock
+    try {
+      return await fn()
+    } finally {
+      release()
+      if (current.locks.get(filepath) === chained) {
+        current.locks.delete(filepath)
+      }
+    }
+  }
+
   export async function assert(sessionID: string, filepath: string) {
     const time = get(sessionID, filepath)
     if (!time) throw new Error(`You must read the file ${filepath} before overwriting it. Use the Read tool first`)

+ 4 - 4
packages/opencode/src/tool/edit.ts

@@ -76,7 +76,7 @@ export const EditTool = Tool.define("edit", {
     let diff = ""
     let contentOld = ""
     let contentNew = ""
-    await (async () => {
+    await FileTime.withLock(filePath, async () => {
       if (params.oldString === "") {
         contentNew = params.newString
         diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
@@ -97,6 +97,7 @@ export const EditTool = Tool.define("edit", {
         await Bus.publish(File.Event.Edited, {
           file: filePath,
         })
+        FileTime.read(ctx.sessionID, filePath)
         return
       }
 
@@ -133,9 +134,8 @@ export const EditTool = Tool.define("edit", {
       diff = trimDiff(
         createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
       )
-    })()
-
-    FileTime.read(ctx.sessionID, filePath)
+      FileTime.read(ctx.sessionID, filePath)
+    })
 
     let output = ""
     await LSP.touchFile(filePath, true)