mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-31 01:37:28 +08:00
fix(cli): prevent VS Code server connection failure on unwritable state paths (#13115)
* fix(cli): fall back from unwritable state directory * fix(cli): verify state directory writes
This commit is contained in:
committed by
GitHub
parent
facd3f1708
commit
d9f0eff306
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Start Kilo with a persistent fallback when the default runtime state directory is not writable.
|
||||
@@ -5,7 +5,7 @@ import os from "os"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Flock } from "./util/flock"
|
||||
import { markNoIndex } from "./kilocode/spotlight" // kilocode_change
|
||||
import { ensureRealDir } from "./kilocode/global" // kilocode_change
|
||||
import { ensureRealDir, resolveState } from "./kilocode/global" // kilocode_change
|
||||
import { Flag } from "./flag/flag"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
|
||||
@@ -22,7 +22,8 @@ const clean = (p: string | undefined) => p?.replace(/[\r\n]+/g, "")
|
||||
const data = path.join(clean(xdgData)!, app)
|
||||
const cache = path.join(clean(xdgCache)!, app)
|
||||
const config = path.join(clean(xdgConfig)!, app)
|
||||
const state = path.join(clean(xdgState)!, app)
|
||||
const preferred = path.join(clean(xdgState)!, app)
|
||||
const state = await resolveState(preferred, process.env.XDG_STATE_HOME ? undefined : path.join(data, "state"))
|
||||
// kilocode_change end
|
||||
const tmp = path.join(os.tmpdir(), app)
|
||||
|
||||
@@ -47,7 +48,6 @@ Flock.setGlobal({ state })
|
||||
await Promise.all([
|
||||
ensureRealDir(Path.data), // kilocode_change
|
||||
ensureRealDir(Path.config), // kilocode_change
|
||||
ensureRealDir(Path.state), // kilocode_change
|
||||
ensureRealDir(Path.tmp), // kilocode_change
|
||||
ensureRealDir(Path.log), // kilocode_change
|
||||
ensureRealDir(Path.bin), // kilocode_change
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { randomUUID } from "crypto"
|
||||
|
||||
/**
|
||||
* Like `fs.mkdir({ recursive: true })` but also repairs broken symlinks and
|
||||
@@ -21,3 +23,50 @@ export async function ensureRealDir(p: string) {
|
||||
await fs.mkdir(p, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function writable(p: string) {
|
||||
const probe = path.join(p, `.kilo-write-${process.pid}-${randomUUID()}`)
|
||||
await fs.writeFile(probe, "", { flag: "wx", mode: 0o600 })
|
||||
await fs.unlink(probe)
|
||||
}
|
||||
|
||||
async function ready(p: string) {
|
||||
await ensureRealDir(p)
|
||||
await writable(p)
|
||||
}
|
||||
|
||||
export async function resolveState(p: string, fallback?: string) {
|
||||
const sticky =
|
||||
fallback === undefined
|
||||
? false
|
||||
: await fs.stat(fallback).then(
|
||||
(stat) =>
|
||||
stat.isDirectory() &&
|
||||
writable(fallback).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
() => false,
|
||||
)
|
||||
if (sticky && fallback !== undefined) return fallback
|
||||
|
||||
const err = await ready(p).then(
|
||||
() => undefined,
|
||||
(err: unknown) => err,
|
||||
)
|
||||
if (err === undefined) return p
|
||||
if (fallback === undefined) throw err
|
||||
|
||||
const failed = await ready(fallback).then(
|
||||
() => undefined,
|
||||
(err: unknown) => err,
|
||||
)
|
||||
if (failed !== undefined) {
|
||||
throw new AggregateError([err, failed], `Cannot use state directory "${p}" or fallback "${fallback}"`)
|
||||
}
|
||||
|
||||
const msg = err instanceof Error ? err.message : "Unknown error"
|
||||
// Logging is not initialized until Global.Path.log exists.
|
||||
console.warn(`[kilo] Cannot use state directory "${p}"; using "${fallback}" instead: ${msg}`)
|
||||
return fallback
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { resolveState } from "@opencode-ai/core/kilocode/global"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
|
||||
const skip = process.platform === "win32" || process.getuid?.() === 0
|
||||
|
||||
describe("global state directory", () => {
|
||||
test("uses the preferred state directory when available", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const preferred = path.join(tmp.path, "preferred")
|
||||
|
||||
expect(await resolveState(preferred, path.join(tmp.path, "fallback"))).toBe(preferred)
|
||||
expect((await fs.stat(preferred)).isDirectory()).toBe(true)
|
||||
expect(await fs.readdir(preferred)).toEqual([])
|
||||
})
|
||||
|
||||
test("falls back when the default state directory is unusable", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const preferred = path.join(tmp.path, "preferred")
|
||||
const fallback = path.join(tmp.path, "data", "state")
|
||||
await fs.writeFile(preferred, "not a directory")
|
||||
|
||||
expect(await resolveState(preferred, fallback)).toBe(fallback)
|
||||
expect((await fs.stat(fallback)).isDirectory()).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps using an existing fallback", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const preferred = path.join(tmp.path, "preferred")
|
||||
const fallback = path.join(tmp.path, "fallback")
|
||||
await fs.mkdir(fallback)
|
||||
|
||||
expect(await resolveState(preferred, fallback)).toBe(fallback)
|
||||
expect(
|
||||
await fs.stat(preferred).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test.skipIf(skip)("uses the preferred directory when an existing fallback is not writable", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const preferred = path.join(tmp.path, "preferred")
|
||||
const fallback = path.join(tmp.path, "fallback")
|
||||
await fs.mkdir(fallback)
|
||||
await fs.chmod(fallback, 0o500)
|
||||
|
||||
try {
|
||||
expect(await resolveState(preferred, fallback)).toBe(preferred)
|
||||
} finally {
|
||||
await fs.chmod(fallback, 0o700)
|
||||
}
|
||||
})
|
||||
|
||||
test.skipIf(skip)("falls back when the preferred directory cannot be created", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const parent = path.join(tmp.path, "preferred")
|
||||
const preferred = path.join(parent, "kilo")
|
||||
const fallback = path.join(tmp.path, "data", "state")
|
||||
await fs.mkdir(parent)
|
||||
await fs.chmod(parent, 0o500)
|
||||
|
||||
try {
|
||||
expect(await resolveState(preferred, fallback)).toBe(fallback)
|
||||
} finally {
|
||||
await fs.chmod(parent, 0o700)
|
||||
}
|
||||
})
|
||||
|
||||
test.skipIf(skip)("falls back when the preferred directory exists but is not writable", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const preferred = path.join(tmp.path, "preferred")
|
||||
const fallback = path.join(tmp.path, "data", "state")
|
||||
await fs.mkdir(preferred)
|
||||
await fs.chmod(preferred, 0o500)
|
||||
|
||||
try {
|
||||
expect(await resolveState(preferred, fallback)).toBe(fallback)
|
||||
} finally {
|
||||
await fs.chmod(preferred, 0o700)
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves errors for explicitly configured state directories", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const preferred = path.join(tmp.path, "preferred")
|
||||
await fs.writeFile(preferred, "not a directory")
|
||||
|
||||
const err = await resolveState(preferred).catch((err: unknown) => err)
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
test("reports both paths when the fallback also fails", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const preferred = path.join(tmp.path, "preferred")
|
||||
const fallback = path.join(tmp.path, "fallback")
|
||||
await Promise.all([fs.writeFile(preferred, "not a directory"), fs.writeFile(fallback, "not a directory")])
|
||||
|
||||
const err = await resolveState(preferred, fallback).catch((err: unknown) => err)
|
||||
expect(err).toBeInstanceOf(AggregateError)
|
||||
if (!(err instanceof AggregateError)) throw err
|
||||
expect(err.message).toContain(preferred)
|
||||
expect(err.message).toContain(fallback)
|
||||
expect(err.errors).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user