fix: Tool calling on windows#4234
Conversation
Replaces global storage lock with per-file lock for write operations and switches from Bun.write to manual file handle operations for writing JSON files. Ensures data is flushed to disk with handle.sync() and creates directories recursively if needed.
There was a problem hiding this comment.
Pull Request Overview
This PR fixes tool calling issues on Windows by addressing file locking and write operation problems. The changes replace a global storage lock with per-file locks and switch from Bun.write to manual file handle operations with explicit syncing.
Key changes:
- Replaced global storage lock (
"storage") with per-file locks using the target file path - Switched from
Bun.writetofs.openwith manualwriteFileandsyncoperations for Windows compatibility - Added directory creation with
recursive: truein thewritefunction
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const content = await Bun.file(target).json() | ||
| fn(content) | ||
| await Bun.write(target, JSON.stringify(content, null, 2)) | ||
| const jsonContent = JSON.stringify(content, null, 2) |
There was a problem hiding this comment.
The file handle is opened without checking if the directory exists first. In the update function, unlike the write function (line 203), there's no fs.mkdir(path.dirname(target), { recursive: true }) call before opening the file. This could fail if the parent directories don't exist yet.
| const jsonContent = JSON.stringify(content, null, 2) | |
| const jsonContent = JSON.stringify(content, null, 2) | |
| await fs.mkdir(path.dirname(target), { recursive: true }) |
|
Weird - it fixed it for me. I'll clean rebuild and try your prompt |
|
There's also an oversight in |
|
Thanks for that extra info @Sewer56 I have got a local repro now, will continue.
|
|
I don't know the codebase that well (or write TS), but I think I figured it with Sonnet's help. There's a race condition.
|
|
Yeah in theory that is why I made the changes to try and call fsync and so forth for the race conditions. I'll dig into it further |
diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts
index 804b21ca0..8758ec18f 100644
--- a/packages/opencode/src/storage/storage.ts
+++ b/packages/opencode/src/storage/storage.ts
@@ -157,6 +157,13 @@ export namespace Storage {
}
})
+ async function atomicRename(source: string, target: string) {
+ if (process.platform === "win32") {
+ await fs.unlink(target).catch(() => {})
+ }
+ await fs.rename(source, target)
+ }
+
export async function remove(key: string[]) {
const dir = await state().then((x) => x.dir)
const target = path.join(dir, ...key) + ".json"
@@ -182,13 +189,15 @@ export namespace Storage {
const content = await Bun.file(target).json()
fn(content)
const jsonContent = JSON.stringify(content, null, 2)
- const handle = await fs.open(target, "w")
+ const tempTarget = target + ".tmp"
+ const handle = await fs.open(tempTarget, "w")
try {
await handle.writeFile(jsonContent, "utf-8")
await handle.sync()
} finally {
await handle.close()
}
+ await atomicRename(tempTarget, target)
return content as T
})
}
@@ -200,13 +209,15 @@ export namespace Storage {
using _ = await Lock.write(target)
const jsonContent = JSON.stringify(content, null, 2)
await fs.mkdir(path.dirname(target), { recursive: true })
- const handle = await fs.open(target, "w")
+ const tempTarget = target + ".tmp"
+ const handle = await fs.open(tempTarget, "w")
try {
await handle.writeFile(jsonContent, "utf-8")
await handle.sync()
} finally {
await handle.close()
}
+ await atomicRename(tempTarget, target)
})
}
This makes it work on my end (what Sonnet spat out), but this can still be improved. |
|
Yup I am trying similar, tmp -> rename. I'll also see if I can revert to bun apis for the file write. Thanks for that |
|
Canonical way to do it is Not sure if TS exposes anything to rename with replacement though. |
|
Since the |
|
Are we sure this is the right comment to make?
The error is more to do with someone else reading it somewhere, right? |
|
Weirdly online threads seem to think that |
|
I'm not a JS guy, but it seems that is actually the key here. |
|
You're right on the money, yeah, I noticed the same thing. I just needed to reboot to Windows once in a blue moon. |
|
Your codes nice too - I'll remove all my extra error handling etc and just keep with bun APIs + that proper await. I'll see how small I can make it |
|
Yup, reverted to Bun APIs too. All sorted here. |
|
Yep I think we have basically the same outcome now comparing our branches. Works great for me now. Nice to figure it out together :) |
|
👏 👏 |
There was a problem hiding this comment.
Pull Request Overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
I wonder why the We changed it so it's a lock on the file path; seems things are still working, so this should in theory be better with less contention. |
|
Yeah it looks like we dont even need temp files. Just the await fixes it. I am curious about the global lock vs target lock |
|
We can also make this cleaner with |
|
Yup. We're really just both figuring out the same things at the same times lol. tbh being a C# dev I prefer the 2 lines for the await rather than wrapping in (). I don't mind though |
|
If you look at my profile, I've always been writing a lot of .NET 😂 |
|
Oh wow yeah your .NET work looks pretty broad and deep! Super impressed |
|
I pinged @rekram1-node in any case (on Discord)
|
|
What a find u two, damn good work |
|
We're trying to fix the .NET stigmas ;) |






Replaces global storage lock with per-file lock for write operations and fixes a non awaited operation
fixes #4042