fix(core): recover corrupted models cache (#30947)
This commit is contained in:
parent
a261b55e43
commit
0ee7cfa1fe
4 changed files with 69 additions and 6 deletions
|
|
@ -84,7 +84,10 @@ export namespace FSUtil {
|
||||||
|
|
||||||
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
|
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
|
||||||
const text = yield* fs.readFileString(path)
|
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) {
|
const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,16 @@ export const layer = Layer.effect(
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadFromDisk = fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).pipe(
|
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),
|
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 fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
|
||||||
const text = yield* fetchApi()
|
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
|
return text
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,21 @@ describe("FSUtil", () => {
|
||||||
expect(result).toEqual(data)
|
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", () => {
|
describe("ensureDir", () => {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { Global } from "@opencode-ai/core/global"
|
||||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { it } from "./lib/effect"
|
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"
|
import path from "path"
|
||||||
|
|
||||||
// test/preload.ts pins OPENCODE_MODELS_PATH to a fixture so other tests can
|
// 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),
|
Layer.provide(EventV2.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
const writeCache = (data: object, mtimeMs?: number) =>
|
const writeCacheText = (text: string, mtimeMs?: number) =>
|
||||||
Effect.promise(async () => {
|
Effect.promise(async () => {
|
||||||
await mkdir(Global.Path.cache, { recursive: true })
|
await mkdir(Global.Path.cache, { recursive: true })
|
||||||
await writeFile(cacheFile, JSON.stringify(data))
|
await writeFile(cacheFile, text)
|
||||||
if (mtimeMs !== undefined) {
|
if (mtimeMs !== undefined) {
|
||||||
const t = mtimeMs / 1000
|
const t = mtimeMs / 1000
|
||||||
await utimes(cacheFile, t, t)
|
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>) =>
|
const provided = <A, E>(state: Ref.Ref<MockState>, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
|
||||||
eff.pipe(Effect.provide(buildLayer(state)))
|
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", () =>
|
it.live("get() is single-flight under concurrent calls", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* writeCache(fixture)
|
yield* writeCache(fixture)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue