summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorSpoon <[email protected]>2025-12-15 06:01:50 +0100
committerGitHub <[email protected]>2025-12-14 23:01:50 -0600
commit5cf126d4894a9707f51017eb56993c05d24c8319 (patch)
tree2674eeb52dcf3a73c84d3d34154c6863d988ea40
parent509f7d961768e56b6b3862adf19779518b3e0d0f (diff)
downloadopencode-5cf126d4894a9707f51017eb56993c05d24c8319.tar.gz
opencode-5cf126d4894a9707f51017eb56993c05d24c8319.zip
fix(edit): add per-file lock to prevent read-before-write race (#4388)
-rw-r--r--packages/opencode/src/file/time.ts26
-rw-r--r--packages/opencode/src/tool/edit.ts8
2 files changed, 30 insertions, 4 deletions
diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts
index 5cba5e820..770427abe 100644
--- a/packages/opencode/src/file/time.ts
+++ b/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`)
diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts
index 62814dbf9..fdf115ac4 100644
--- a/packages/opencode/src/tool/edit.ts
+++ b/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)