Kaynağa Gözat

fix(git): stop leaking fsmonitor daemons

LukeParkerDev 1 ay önce
ebeveyn
işleme
f49f3230cf

+ 5 - 1
packages/app/e2e/actions.ts

@@ -197,6 +197,7 @@ export async function createTestProject() {
   await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
 
   execSync("git init", { cwd: root, stdio: "ignore" })
+  execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
   execSync("git add -A", { cwd: root, stdio: "ignore" })
   execSync('git -c user.name="e2e" -c user.email="[email protected]" commit -m "init" --allow-empty', {
     cwd: root,
@@ -207,7 +208,10 @@ export async function createTestProject() {
 }
 
 export async function cleanupTestProject(directory: string) {
-  await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
+  try {
+    execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
+  } catch {}
+  await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
 }
 
 export function sessionIDFromUrl(url: string) {

+ 26 - 14
packages/opencode/src/file/index.ts

@@ -418,7 +418,7 @@ export namespace File {
     const project = Instance.project
     if (project.vcs !== "git") return []
 
-    const diffOutput = await $`git -c core.quotepath=false diff --numstat HEAD`
+    const diffOutput = await $`git -c core.fsmonitor=false -c core.quotepath=false diff --numstat HEAD`
       .cwd(Instance.directory)
       .quiet()
       .nothrow()
@@ -439,11 +439,12 @@ export namespace File {
       }
     }
 
-    const untrackedOutput = await $`git -c core.quotepath=false ls-files --others --exclude-standard`
-      .cwd(Instance.directory)
-      .quiet()
-      .nothrow()
-      .text()
+    const untrackedOutput =
+      await $`git -c core.fsmonitor=false -c core.quotepath=false ls-files --others --exclude-standard`
+        .cwd(Instance.directory)
+        .quiet()
+        .nothrow()
+        .text()
 
     if (untrackedOutput.trim()) {
       const untrackedFiles = untrackedOutput.trim().split("\n")
@@ -464,11 +465,12 @@ export namespace File {
     }
 
     // Get deleted files
-    const deletedOutput = await $`git -c core.quotepath=false diff --name-only --diff-filter=D HEAD`
-      .cwd(Instance.directory)
-      .quiet()
-      .nothrow()
-      .text()
+    const deletedOutput =
+      await $`git -c core.fsmonitor=false -c core.quotepath=false diff --name-only --diff-filter=D HEAD`
+        .cwd(Instance.directory)
+        .quiet()
+        .nothrow()
+        .text()
 
     if (deletedOutput.trim()) {
       const deletedFiles = deletedOutput.trim().split("\n")
@@ -539,10 +541,20 @@ export namespace File {
     const content = (await Filesystem.readText(full).catch(() => "")).trim()
 
     if (project.vcs === "git") {
-      let diff = await $`git diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
-      if (!diff.trim()) diff = await $`git diff --staged ${file}`.cwd(Instance.directory).quiet().nothrow().text()
+      let diff = await $`git -c core.fsmonitor=false diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
+      if (!diff.trim()) {
+        diff = await $`git -c core.fsmonitor=false diff --staged ${file}`
+          .cwd(Instance.directory)
+          .quiet()
+          .nothrow()
+          .text()
+      }
       if (diff.trim()) {
-        const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text()
+        const original = await $`git -c core.fsmonitor=false show HEAD:${file}`
+          .cwd(Instance.directory)
+          .quiet()
+          .nothrow()
+          .text()
         const patch = structuredPatch(file, file, original, content, "old", "new", {
           context: Infinity,
           ignoreWhitespace: true,

+ 8 - 1
packages/opencode/src/worktree/index.ts

@@ -474,6 +474,11 @@ export namespace Worktree {
           throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
         })
 
+    const stop = async (target: string) => {
+      if (!(await exists(target))) return
+      await $`git fsmonitor--daemon stop`.quiet().nothrow().cwd(target)
+    }
+
     const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
     if (list.exitCode !== 0) {
       throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
@@ -484,11 +489,13 @@ export namespace Worktree {
     if (!entry?.path) {
       const directoryExists = await exists(directory)
       if (directoryExists) {
+        await stop(directory)
         await clean(directory)
       }
       return true
     }
 
+    await stop(entry.path)
     const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
     if (removed.exitCode !== 0) {
       const next = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
@@ -637,7 +644,7 @@ export namespace Worktree {
       throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" })
     }
 
-    const status = await $`git status --porcelain=v1`.quiet().nothrow().cwd(worktreePath)
+    const status = await $`git -c core.fsmonitor=false status --porcelain=v1`.quiet().nothrow().cwd(worktreePath)
     if (status.exitCode !== 0) {
       throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" })
     }

+ 62 - 0
packages/opencode/test/file/fsmonitor.test.ts

@@ -0,0 +1,62 @@
+import { $ } from "bun"
+import { describe, expect, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { File } from "../../src/file"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+
+const wintest = process.platform === "win32" ? test : test.skip
+
+describe("file fsmonitor", () => {
+  wintest("status does not start fsmonitor for readonly git checks", async () => {
+    await using tmp = await tmpdir({ git: true })
+    const target = path.join(tmp.path, "tracked.txt")
+
+    await fs.writeFile(target, "base\n")
+    await $`git add tracked.txt`.cwd(tmp.path).quiet()
+    await $`git commit -m init`.cwd(tmp.path).quiet()
+    await $`git config core.fsmonitor true`.cwd(tmp.path).quiet()
+    await $`git fsmonitor--daemon stop`.cwd(tmp.path).quiet().nothrow()
+    await fs.writeFile(target, "next\n")
+    await fs.writeFile(path.join(tmp.path, "new.txt"), "new\n")
+
+    const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
+    expect(before.exitCode).not.toBe(0)
+
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        await File.status()
+      },
+    })
+
+    const after = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
+    expect(after.exitCode).not.toBe(0)
+  })
+
+  wintest("read does not start fsmonitor for git diffs", async () => {
+    await using tmp = await tmpdir({ git: true })
+    const target = path.join(tmp.path, "tracked.txt")
+
+    await fs.writeFile(target, "base\n")
+    await $`git add tracked.txt`.cwd(tmp.path).quiet()
+    await $`git commit -m init`.cwd(tmp.path).quiet()
+    await $`git config core.fsmonitor true`.cwd(tmp.path).quiet()
+    await $`git fsmonitor--daemon stop`.cwd(tmp.path).quiet().nothrow()
+    await fs.writeFile(target, "next\n")
+
+    const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
+    expect(before.exitCode).not.toBe(0)
+
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        await File.read("tracked.txt")
+      },
+    })
+
+    const after = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
+    expect(after.exitCode).not.toBe(0)
+  })
+})

+ 26 - 0
packages/opencode/test/fixture/fixture.test.ts

@@ -0,0 +1,26 @@
+import { $ } from "bun"
+import { describe, expect, test } from "bun:test"
+import fs from "fs/promises"
+import { tmpdir } from "./fixture"
+
+describe("tmpdir", () => {
+  test("disables fsmonitor for git fixtures", async () => {
+    await using tmp = await tmpdir({ git: true })
+
+    const value = (await $`git config core.fsmonitor`.cwd(tmp.path).quiet().text()).trim()
+    expect(value).toBe("false")
+  })
+
+  test("removes directories on dispose", async () => {
+    const tmp = await tmpdir({ git: true })
+    const dir = tmp.path
+
+    await tmp[Symbol.asyncDispose]()
+
+    const exists = await fs
+      .stat(dir)
+      .then(() => true)
+      .catch(() => false)
+    expect(exists).toBe(false)
+  })
+})

+ 28 - 2
packages/opencode/test/fixture/fixture.ts

@@ -9,6 +9,27 @@ function sanitizePath(p: string): string {
   return p.replace(/\0/g, "")
 }
 
+function exists(dir: string) {
+  return fs
+    .stat(dir)
+    .then(() => true)
+    .catch(() => false)
+}
+
+function clean(dir: string) {
+  return fs.rm(dir, {
+    recursive: true,
+    force: true,
+    maxRetries: 5,
+    retryDelay: 100,
+  })
+}
+
+async function stop(dir: string) {
+  if (!(await exists(dir))) return
+  await $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow()
+}
+
 type TmpDirOptions<T> = {
   git?: boolean
   config?: Partial<Config.Info>
@@ -20,6 +41,7 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
   await fs.mkdir(dirpath, { recursive: true })
   if (options?.git) {
     await $`git init`.cwd(dirpath).quiet()
+    await $`git config core.fsmonitor false`.cwd(dirpath).quiet()
     await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet()
   }
   if (options?.config) {
@@ -35,8 +57,12 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
   const realpath = sanitizePath(await fs.realpath(dirpath))
   const result = {
     [Symbol.asyncDispose]: async () => {
-      await options?.dispose?.(dirpath)
-      // await fs.rm(dirpath, { recursive: true, force: true })
+      try {
+        await options?.dispose?.(realpath)
+      } finally {
+        if (options?.git) await stop(realpath)
+        await clean(realpath)
+      }
     },
     path: realpath,
     extra: extra as T,

+ 31 - 0
packages/opencode/test/project/worktree-remove.test.ts

@@ -7,6 +7,8 @@ import { Worktree } from "../../src/worktree"
 import { Filesystem } from "../../src/util/filesystem"
 import { tmpdir } from "../fixture/fixture"
 
+const wintest = process.platform === "win32" ? test : test.skip
+
 describe("Worktree.remove", () => {
   test("continues when git remove exits non-zero after detaching", async () => {
     await using tmp = await tmpdir({ git: true })
@@ -62,4 +64,33 @@ describe("Worktree.remove", () => {
     const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow()
     expect(ref.exitCode).not.toBe(0)
   })
+
+  wintest("stops fsmonitor before removing a worktree", async () => {
+    await using tmp = await tmpdir({ git: true })
+    const root = tmp.path
+    const name = `remove-fsmonitor-${Date.now().toString(36)}`
+    const branch = `opencode/${name}`
+    const dir = path.join(root, "..", name)
+
+    await $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet()
+    await $`git reset --hard`.cwd(dir).quiet()
+    await $`git config core.fsmonitor true`.cwd(dir).quiet()
+    await $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow()
+    await Bun.write(path.join(dir, "tracked.txt"), "next\n")
+    await $`git diff`.cwd(dir).quiet()
+
+    const before = await $`git fsmonitor--daemon status`.cwd(dir).quiet().nothrow()
+    expect(before.exitCode).toBe(0)
+
+    const ok = await Instance.provide({
+      directory: root,
+      fn: () => Worktree.remove({ directory: dir }),
+    })
+
+    expect(ok).toBe(true)
+    expect(await Filesystem.exists(dir)).toBe(false)
+
+    const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow()
+    expect(ref.exitCode).not.toBe(0)
+  })
 })