Bladeren bron

fix(core): bound fuzzy search memory (#42741)

Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
opencode-agent[bot] 1 dag geleden
bovenliggende
commit
cae205a3d9
2 gewijzigde bestanden met toevoegingen van 92 en 21 verwijderingen
  1. 36 20
      packages/core/src/filesystem/search.ts
  2. 56 1
      packages/core/test/filesystem/search.test.ts

+ 36 - 20
packages/core/src/filesystem/search.ts

@@ -23,6 +23,32 @@ export type Options = typeof Options.Type
 export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {}
 
 const REFRESH_INTERVAL = Duration.toMillis("10 seconds")
+type Prepared = ReturnType<typeof fuzzysort.prepare>
+
+function emptyIndex() {
+  return { files: new Map<string, Prepared>(), directories: new Map<string, Prepared>() }
+}
+
+function search(index: ReturnType<typeof emptyIndex>, input: FileSystem.FindInput) {
+  const items =
+    input.type === "file"
+      ? Array.from(index.files.values())
+      : input.type === "directory"
+        ? Array.from(index.directories.values())
+        : [...index.files.values(), ...index.directories.values()]
+  const result = fuzzysort.go(input.query, items, { limit: input.limit ?? 50 })
+  // Targets are owned by the current location index. The only global fuzzysort
+  // state left is its query cache, which must not retain every query forever.
+  fuzzysort.cleanup()
+  return result.map((item) => {
+    const relative = item.target
+    const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
+    return FileSystem.Entry.make({
+      path: RelativePath.make(relative),
+      type,
+    })
+  })
+}
 
 export const ripgrepLayer = Layer.effect(
   Service,
@@ -32,12 +58,13 @@ export const ripgrepLayer = Layer.effect(
     const scope = yield* Scope.Scope
     const clock = yield* Clock.Clock
     const home = Protected.isHome(location.directory)
-    let index = { files: [] as string[], directories: new Set<string>() }
+    let index = emptyIndex()
     let initialized = false
     let settledAt = Number.NEGATIVE_INFINITY
     let refreshing = false
     const scan = Effect.gen(function* () {
-      const next = { files: [] as string[], directories: new Set<string>() }
+      const next = emptyIndex()
+      const previous = index
       if (!initialized) index = next
       yield* ripgrep.find({
         cwd: location.directory,
@@ -46,11 +73,13 @@ export const ripgrepLayer = Layer.effect(
         exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
         onEntry: (entry) =>
           Effect.sync(() => {
-            next.files.push(entry.path)
+            next.files.set(entry.path, previous.files.get(entry.path) ?? fuzzysort.prepare(entry.path))
             const parts = entry.path.split("/")
-            parts
-              .slice(0, -1)
-              .forEach((_, offset) => next.directories.add(parts.slice(0, offset + 1).join("/") + path.sep))
+            parts.slice(0, -1).forEach((_, offset) => {
+              const directory = parts.slice(0, offset + 1).join("/") + path.sep
+              if (!next.directories.has(directory))
+                next.directories.set(directory, previous.directories.get(directory) ?? fuzzysort.prepare(directory))
+            })
           }),
       })
       index = next
@@ -74,20 +103,7 @@ export const ripgrepLayer = Layer.effect(
       find: (input) =>
         Effect.gen(function* () {
           yield* refresh
-          const items =
-            input.type === "file"
-              ? index.files
-              : input.type === "directory"
-                ? Array.from(index.directories)
-                : [...index.files, ...index.directories]
-          return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
-            const relative = item.target
-            const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
-            return FileSystem.Entry.make({
-              path: RelativePath.make(relative),
-              type,
-            })
-          })
+          return search(index, input)
         }),
     })
   }),

+ 56 - 1
packages/core/test/filesystem/search.test.ts

@@ -1,4 +1,5 @@
-import { describe, expect, test } from "bun:test"
+import { describe, expect, spyOn, test } from "bun:test"
+import fuzzysort from "fuzzysort"
 import os from "os"
 import path from "path"
 import { Deferred, Effect, Layer } from "effect"
@@ -123,4 +124,58 @@ describe("FileSystemSearch", () => {
       }).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
     )
   })
+
+  test("reuses location-owned fuzzy targets across index refreshes", async () => {
+    let scans = 0
+    const first = Effect.runSync(Deferred.make<void>())
+    const second = Effect.runSync(Deferred.make<void>())
+    const prepare = spyOn(fuzzysort, "prepare")
+    const cleanup = spyOn(fuzzysort, "cleanup")
+    const layer = AppNodeBuilder.build(FileSystemSearch.node, [
+      [
+        Location.node,
+        Layer.succeed(
+          Location.Service,
+          Location.Service.of(
+            location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
+          ),
+        ),
+      ],
+      [
+        Ripgrep.node,
+        Layer.succeed(
+          Ripgrep.Service,
+          Ripgrep.Service.of({
+            find: (input) =>
+              Effect.gen(function* () {
+                scans++
+                const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
+                if (input.onEntry) yield* input.onEntry(entry)
+                yield* Deferred.succeed(scans === 1 ? first : second, undefined)
+                return [entry]
+              }),
+            glob: () => Effect.succeed([]),
+            grep: () => Effect.succeed([]),
+          }),
+        ),
+      ],
+    ])
+
+    await Effect.runPromise(
+      Effect.gen(function* () {
+        const search = yield* FileSystemSearch.Service
+        yield* Deferred.await(first)
+        yield* search.find({ query: "index", type: "file" })
+        yield* TestClock.adjust("10 seconds")
+        yield* search.find({ query: "index", type: "file" })
+        yield* Deferred.await(second)
+        yield* search.find({ query: "index", type: "file" })
+
+        expect(prepare).toHaveBeenCalledTimes(2)
+        expect(cleanup).toHaveBeenCalledTimes(3)
+      }).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
+    )
+    prepare.mockRestore()
+    cleanup.mockRestore()
+  })
 })