Переглянути джерело

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

Shoubhit Dash 2 місяців тому
батько
коміт
0ee7cfa1fe

+ 4 - 1
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) {

+ 20 - 2
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<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
     })
 

+ 15 - 0
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", () => {

+ 30 - 3
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<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)