mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-19 23:24:42 +00:00
fix(git): stop leaking fsmonitor daemons e.g. 60GB+ of commited memory after running tests (#16249)
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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" })
|
||||||
}
|
}
|
||||||
|
|||||||
62
packages/opencode/test/file/fsmonitor.test.ts
Normal file
62
packages/opencode/test/file/fsmonitor.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
26
packages/opencode/test/fixture/fixture.test.ts
Normal file
26
packages/opencode/test/fixture/fixture.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user