summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorLuke Parker <[email protected]>2026-03-06 15:42:08 +1000
committerGitHub <[email protected]>2026-03-06 15:42:08 +1000
commitaec6ca71fa544ca8b5e4997f5475abc72e6e924d (patch)
treec254e9684bea1a01804c5c7cf7c171a22ba3644a /packages
parentc04da45be51994496f44e4fe238fbffbec89cb37 (diff)
downloadopencode-aec6ca71fa544ca8b5e4997f5475abc72e6e924d.tar.gz
opencode-aec6ca71fa544ca8b5e4997f5475abc72e6e924d.zip
fix(git): stop leaking fsmonitor daemons e.g. 60GB+ of commited memory after running tests (#16249)
Diffstat (limited to 'packages')
-rw-r--r--packages/app/e2e/actions.ts6
-rw-r--r--packages/opencode/src/file/index.ts34
-rw-r--r--packages/opencode/src/worktree/index.ts9
-rw-r--r--packages/opencode/test/file/fsmonitor.test.ts62
-rw-r--r--packages/opencode/test/fixture/fixture.test.ts26
-rw-r--r--packages/opencode/test/fixture/fixture.ts32
-rw-r--r--packages/opencode/test/project/worktree-remove.test.ts31
7 files changed, 182 insertions, 18 deletions
diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts
index a7ccba617..fbb13008b 100644
--- a/packages/app/e2e/actions.ts
+++ b/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) {
diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts
index b7daddc5f..01f07c9af 100644
--- a/packages/opencode/src/file/index.ts
+++ b/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,8 +541,14 @@ 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 patch = structuredPatch(file, file, original, content, "old", "new", {
diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts
index 226732249..15efcea38 100644
--- a/packages/opencode/src/worktree/index.ts
+++ b/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" })
}
diff --git a/packages/opencode/test/file/fsmonitor.test.ts b/packages/opencode/test/file/fsmonitor.test.ts
new file mode 100644
index 000000000..8cdde014d
--- /dev/null
+++ b/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)
+ })
+})
diff --git a/packages/opencode/test/fixture/fixture.test.ts b/packages/opencode/test/fixture/fixture.test.ts
new file mode 100644
index 000000000..153276a28
--- /dev/null
+++ b/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)
+ })
+})
diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts
index ed8c5e344..63f93bcaf 100644
--- a/packages/opencode/test/fixture/fixture.ts
+++ b/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) {
@@ -31,12 +53,16 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
}),
)
}
- const extra = await options?.init?.(dirpath)
const realpath = sanitizePath(await fs.realpath(dirpath))
+ const extra = await options?.init?.(realpath)
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).catch(() => undefined)
+ await clean(realpath).catch(() => undefined)
+ }
},
path: realpath,
extra: extra as T,
diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts
index e17a5392b..a6b5bb7c3 100644
--- a/packages/opencode/test/project/worktree-remove.test.ts
+++ b/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)
+ })
})