fix(core): recover corrupted models cache (#30947)

This commit is contained in:
Shoubhit Dash 2026-06-05 18:07:09 +05:30 committed by GitHub
parent a261b55e43
commit 0ee7cfa1fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 69 additions and 6 deletions

View File

@ -84,7 +84,10 @@ export namespace FSUtil {
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
const text = yield* fs.readFileString(path)
return JSON.parse(text)
return yield* Effect.try({
try: () => JSON.parse(text),
catch: (cause) => new FileSystemError({ method: "readJson", cause }),
})
})
const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {

View File

@ -162,7 +162,16 @@ export const layer = Layer.effect(
})
const loadFromDisk = fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).pipe(
Effect.catch(() => Effect.succeed(undefined)),
Effect.catch((error) => {
if (
Flag.OPENCODE_MODELS_PATH === undefined &&
error._tag === "FileSystemError" &&
error.method === "readJson"
) {
return fs.remove(filepath, { force: true }).pipe(Effect.ignore, Effect.as(undefined))
}
return Effect.succeed(undefined)
}),
Effect.map((v) => v as Record<string, Provider> | undefined),
)
@ -172,7 +181,16 @@ export const layer = Layer.effect(
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
yield* fs.writeWithDirs(filepath, text)
const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp`
yield* fs.writeWithDirs(tempfile, text).pipe(
Effect.andThen(fs.rename(tempfile, filepath)),
Effect.catch((error) =>
Effect.gen(function* () {
yield* fs.remove(tempfile, { force: true }).pipe(Effect.ignore)
return yield* Effect.fail(error)
}),
),
)
return text
})

View File

@ -109,6 +109,21 @@ describe("FSUtil", () => {
expect(result).toEqual(data)
}),
)
it(
"fails invalid JSON through the error channel",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
const file = path.join(tmp, "broken.json")
yield* filesys.writeFileString(file, "{")
const result = yield* fs.readJson(file).pipe(Effect.catch((error) => Effect.succeed(error)))
expect(result).toHaveProperty("_tag", "FileSystemError")
}),
)
})
describe("ensureDir", () => {

View File

@ -7,7 +7,7 @@ import { Global } from "@opencode-ai/core/global"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { EventV2 } from "@opencode-ai/core/event"
import { it } from "./lib/effect"
import { rm, writeFile, utimes, mkdir } from "fs/promises"
import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises"
import path from "path"
// test/preload.ts pins OPENCODE_MODELS_PATH to a fixture so other tests can
@ -96,16 +96,18 @@ const buildLayer = (state: Ref.Ref<MockState>) =>
Layer.provide(EventV2.defaultLayer),
)
const writeCache = (data: object, mtimeMs?: number) =>
const writeCacheText = (text: string, mtimeMs?: number) =>
Effect.promise(async () => {
await mkdir(Global.Path.cache, { recursive: true })
await writeFile(cacheFile, JSON.stringify(data))
await writeFile(cacheFile, text)
if (mtimeMs !== undefined) {
const t = mtimeMs / 1000
await utimes(cacheFile, t, t)
}
})
const writeCache = (data: object, mtimeMs?: number) => writeCacheText(JSON.stringify(data), mtimeMs)
const provided = <A, E>(state: Ref.Ref<MockState>, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
eff.pipe(Effect.provide(buildLayer(state)))
@ -151,6 +153,31 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() recovers from a corrupted cache file by fetching a fresh catalog", () =>
Effect.gen(function* () {
yield* writeCacheText("{")
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const result = yield* Effect.acquireUseRelease(
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = false
}),
() =>
provided(
state,
ModelsDev.Service.use((s) => s.get()),
),
() =>
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
}),
)
expect(result).toEqual(fixture2)
expect(yield* Effect.promise(() => readFile(cacheFile, "utf8"))).toBe(JSON.stringify(fixture2))
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
)
it.live("get() is single-flight under concurrent calls", () =>
Effect.gen(function* () {
yield* writeCache(fixture)