fix(git): stop leaking fsmonitor daemons e.g. 60GB+ of commited memory after running tests (#16249)

This commit is contained in:
Luke Parker
2026-03-06 15:42:08 +10:00
committed by GitHub
parent c04da45be5
commit aec6ca71fa
7 changed files with 182 additions and 18 deletions

View File

@@ -197,6 +197,7 @@ export async function createTestProject() {
await fs.writeFile(path.join(root, "README.md"), "# e2e\n") await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
execSync("git init", { cwd: root, stdio: "ignore" }) 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 add -A", { cwd: root, stdio: "ignore" })
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', { execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
cwd: root, cwd: root,
@@ -207,7 +208,10 @@ export async function createTestProject() {
} }
export async function cleanupTestProject(directory: string) { 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) { export function sessionIDFromUrl(url: string) {

View File

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

View File

@@ -474,6 +474,11 @@ export namespace Worktree {
throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" }) 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) const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
if (list.exitCode !== 0) { if (list.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" }) throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
@@ -484,11 +489,13 @@ export namespace Worktree {
if (!entry?.path) { if (!entry?.path) {
const directoryExists = await exists(directory) const directoryExists = await exists(directory)
if (directoryExists) { if (directoryExists) {
await stop(directory)
await clean(directory) await clean(directory)
} }
return true return true
} }
await stop(entry.path)
const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree) const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
if (removed.exitCode !== 0) { if (removed.exitCode !== 0) {
const next = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree) 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" }) 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) { if (status.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" }) throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" })
} }

View File

@@ -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)
})
})

View File

@@ -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)
})
})

View File

@@ -9,6 +9,27 @@ function sanitizePath(p: string): string {
return p.replace(/\0/g, "") 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> = { type TmpDirOptions<T> = {
git?: boolean git?: boolean
config?: Partial<Config.Info> config?: Partial<Config.Info>
@@ -20,6 +41,7 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
await fs.mkdir(dirpath, { recursive: true }) await fs.mkdir(dirpath, { recursive: true })
if (options?.git) { if (options?.git) {
await $`git init`.cwd(dirpath).quiet() 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() await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet()
} }
if (options?.config) { 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 realpath = sanitizePath(await fs.realpath(dirpath))
const extra = await options?.init?.(realpath)
const result = { const result = {
[Symbol.asyncDispose]: async () => { [Symbol.asyncDispose]: async () => {
await options?.dispose?.(dirpath) try {
// await fs.rm(dirpath, { recursive: true, force: true }) await options?.dispose?.(realpath)
} finally {
if (options?.git) await stop(realpath).catch(() => undefined)
await clean(realpath).catch(() => undefined)
}
}, },
path: realpath, path: realpath,
extra: extra as T, extra: extra as T,

View File

@@ -7,6 +7,8 @@ import { Worktree } from "../../src/worktree"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
const wintest = process.platform === "win32" ? test : test.skip
describe("Worktree.remove", () => { describe("Worktree.remove", () => {
test("continues when git remove exits non-zero after detaching", async () => { test("continues when git remove exits non-zero after detaching", async () => {
await using tmp = await tmpdir({ git: true }) 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() const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow()
expect(ref.exitCode).not.toBe(0) 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)
})
}) })