diff --git a/packages/core/src/fs-util.ts b/packages/core/src/fs-util.ts index 666396fe32c9..82ddd40483cc 100644 --- a/packages/core/src/fs-util.ts +++ b/packages/core/src/fs-util.ts @@ -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) { diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 7c7a185a6509..64eede422d85 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -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 | 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 }) diff --git a/packages/core/test/filesystem/filesystem.test.ts b/packages/core/test/filesystem/filesystem.test.ts index 635148ba6434..10f61d8a97f9 100644 --- a/packages/core/test/filesystem/filesystem.test.ts +++ b/packages/core/test/filesystem/filesystem.test.ts @@ -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", () => { diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 66288afae0a0..31a3e57c10c6 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -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) => 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 = (state: Ref.Ref, eff: Effect.Effect) => 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)