Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/windows-locked-file-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Retry transient locked-file errors (EPERM/EACCES/EBUSY) on Windows when atomically saving config and other files. Background plugin installs and Windows Defender/indexer can briefly hold the temp file during the rename step, which previously surfaced as a 500 error. A short backoff now retries the rename so config writes succeed without surfacing the contention.
35 changes: 29 additions & 6 deletions packages/opencode/src/util/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ function isEnoent(e: unknown): e is { code: "ENOENT" } {
return typeof e === "object" && e !== null && "code" in e && (e as { code: string }).code === "ENOENT"
}

// kilocode_change start - Windows transient locked-file errors on atomic rename
// Defender/indexer and concurrent writers (e.g. background plugin install) can
// briefly hold the temp file, making MoveFileEx fail with EPERM/EACCES/EBUSY.
// Retry with a short backoff instead of surfacing a 500; POSIX renames are atomic
// so the retry path only fires under contention and never changes success semantics.
function isLocked(e: unknown): boolean {
return (
typeof e === "object" &&
e !== null &&
"code" in e &&
["EBUSY", "EACCES", "EPERM"].includes(String((e as { code: string }).code))
)
}
// kilocode_change end

export async function write(p: string, content: string | Buffer | Uint8Array, mode?: number): Promise<void> {
// kilocode_change start - atomic write via temp-file + rename to avoid partial reads on concurrent saves
// Include a random suffix so that concurrent writes to the same path never share a temp file,
Expand All @@ -79,15 +94,23 @@ export async function write(p: string, content: string | Buffer | Uint8Array, mo
}
await rename(tmp, p)
}
try {
await doWrite()
} catch (e) {
if (isEnoent(e)) {
await mkdir(dirname(p), { recursive: true })
const attempts = process.platform === "win32" ? 8 : 1
for (let attempt = 1; ; attempt++) {
try {
await doWrite()
return
} catch (e) {
if (isEnoent(e)) {
await mkdir(dirname(p), { recursive: true })
await doWrite()
return
}
if (isLocked(e) && attempt < attempts) {
await Bun.sleep(50 * attempt)
continue
}
throw e
}
throw e
}
// kilocode_change end
}
Expand Down
11 changes: 10 additions & 1 deletion packages/opencode/test/kilocode/server/httpapi-kilo-edit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,16 @@ const edit = {

function app() {
const handler = HttpRouter.toWebHandler(
HttpApiServer.routes.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({})))),
// kilocode_change - keep the filewatcher-disable flag visible (see httpapi-instance-route-auth.test.ts)
HttpApiServer.routes.pipe(
Layer.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: process.env.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER ?? "true",
}),
),
),
),
{ disableLogger: true },
).handler

Expand Down
11 changes: 10 additions & 1 deletion packages/opencode/test/kilocode/server/httpapi-memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,16 @@ type Json = Record<string, unknown>

function app() {
const handler = HttpRouter.toWebHandler(
HttpApiServer.routes.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({})))),
// kilocode_change - keep the filewatcher-disable flag visible (see httpapi-instance-route-auth.test.ts)
HttpApiServer.routes.pipe(
Layer.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: process.env.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER ?? "true",
}),
),
),
),
{ disableLogger: true },
).handler

Expand Down
11 changes: 10 additions & 1 deletion packages/opencode/test/server/httpapi-cors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,16 @@ describe("HttpApi CORS", () => {
Effect.gen(function* () {
const handler = HttpRouter.toWebHandler(
HttpApiApp.createRoutes().pipe(
Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ KILO_SERVER_PASSWORD: "secret" }))),
// kilocode_change start - keep the filewatcher-disable flag visible (see httpapi-instance-route-auth.test.ts)
Layer.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({
KILO_SERVER_PASSWORD: "secret",
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: process.env.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER ?? "true",
}),
),
),
// kilocode_change end
),
{ disableLogger: true },
).handler
Expand Down
8 changes: 7 additions & 1 deletion packages/opencode/test/server/httpapi-exercise/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,15 @@ function app(modules: Runtime, options: CallOptions) {
const web = HttpRouter.toWebHandler(
modules.HttpApiApp.routes.pipe(
Layer.provide(
// kilocode_change start - keep the filewatcher-disable flag visible (see httpapi-instance-route-auth.test.ts)
ConfigProvider.layer(
ConfigProvider.fromUnknown({ KILO_SERVER_PASSWORD: password, KILO_SERVER_USERNAME: username }),
ConfigProvider.fromUnknown({
KILO_SERVER_PASSWORD: password,
KILO_SERVER_USERNAME: username,
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: process.env.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER ?? "true",
}),
),
// kilocode_change end
),
),
{ disableLogger: true, memoMap: modules.memoMap },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@ function app(input: { password?: string; username?: string }) {
HttpApiApp.routes.pipe(
Layer.provide(
ConfigProvider.layer(
// kilocode_change start - keep the filewatcher-disable flag visible so the
// @parcel/watcher Windows backend does not subscribe on temp dirs that
// the tmpdir fixture deletes mid-test (throws "Invalid handle").
ConfigProvider.fromUnknown({
KILO_SERVER_PASSWORD: input.password,
KILO_SERVER_USERNAME: input.username,
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: process.env.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER ?? "true",
}),
// kilocode_change end
),
),
),
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/test/server/httpapi-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,15 @@ function app(input?: { password?: string; username?: string }) {
const handler = HttpRouter.toWebHandler(
HttpApiApp.routes.pipe(
Layer.provide(
// kilocode_change start - keep the filewatcher-disable flag visible (see httpapi-instance-route-auth.test.ts)
ConfigProvider.layer(
ConfigProvider.fromUnknown({
KILO_SERVER_PASSWORD: input?.password,
KILO_SERVER_USERNAME: input?.username,
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: process.env.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER ?? "true",
}),
),
// kilocode_change end
),
),
{ disableLogger: true },
Expand Down Expand Up @@ -100,12 +103,15 @@ function uiApp(input?: {
input?.client ?? httpClient(new Response("ui")),
RuntimeFlags.layer({ disableEmbeddedWebUi: input?.disableEmbeddedWebUi ?? false }),
HttpServer.layerServices,
// kilocode_change start - keep the filewatcher-disable flag visible (see httpapi-instance-route-auth.test.ts)
ConfigProvider.layer(
ConfigProvider.fromUnknown({
KILO_SERVER_PASSWORD: input?.password,
KILO_SERVER_USERNAME: input?.username,
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: process.env.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER ?? "true",
}),
),
// kilocode_change end
]),
),
{ disableLogger: true },
Expand Down
Loading