Skip to content

Commit bf77631

Browse files
ricardo-leivaclaude
andcommitted
fix(fs): cap file list and timeout untracked scan to prevent session init OOM
Large repos (100k+ files, unignored __pycache__ / venvs) caused git ls-files --others to hang for minutes and allocate hundreds of MB in the workspace-server process. The resulting GC pressure starved the concurrent session initializationResult() promise, reliably hitting the 30s timeout when adding a large project. Two-part fix in FsService.listRepoFiles: - Abort git ls-files --others after 8 s via AbortController; fall back to [] - Cap combined tracked + untracked array at 50,000 entries before building the directory tree (avoids ~200MB+ allocation for very large repos) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpGjPYBD4pgZpsAHjozzsN
1 parent f9f844a commit bf77631

2 files changed

Lines changed: 70 additions & 7 deletions

File tree

packages/workspace-server/src/services/fs/service.test.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
55

66
vi.mock("@posthog/git/queries", () => ({
77
getChangedFiles: vi.fn(async () => new Set<string>()),
8-
listAllFiles: vi.fn(async () => []),
8+
listFiles: vi.fn(async () => []),
9+
listUntrackedFiles: vi.fn(async () => []),
910
}));
1011

11-
import { getChangedFiles, listAllFiles } from "@posthog/git/queries";
12+
import {
13+
getChangedFiles,
14+
listFiles,
15+
listUntrackedFiles,
16+
} from "@posthog/git/queries";
1217
import { FsService } from "./service";
1318

1419
describe("FsService.listRepoFiles", () => {
1520
it("derives directory entries alongside files", async () => {
1621
vi.mocked(getChangedFiles).mockResolvedValue(new Set());
17-
vi.mocked(listAllFiles).mockResolvedValue([
22+
vi.mocked(listFiles).mockResolvedValue([
1823
"a.ts",
1924
"src/b.ts",
2025
"src/sub/c.ts",
2126
]);
27+
vi.mocked(listUntrackedFiles).mockResolvedValue([]);
2228

2329
const service = new FsService();
2430
const entries = await service.listRepoFiles("/repo");
@@ -34,11 +40,12 @@ describe("FsService.listRepoFiles", () => {
3440

3541
it("filters directories and files by query substring", async () => {
3642
vi.mocked(getChangedFiles).mockResolvedValue(new Set());
37-
vi.mocked(listAllFiles).mockResolvedValue([
43+
vi.mocked(listFiles).mockResolvedValue([
3844
"a.ts",
3945
"src/b.ts",
4046
"src/sub/c.ts",
4147
]);
48+
vi.mocked(listUntrackedFiles).mockResolvedValue([]);
4249

4350
const service = new FsService();
4451
const entries = await service.listRepoFiles("/repo", "sub");
@@ -48,6 +55,29 @@ describe("FsService.listRepoFiles", () => {
4855
{ path: "src/sub/c.ts", kind: "file" },
4956
]);
5057
});
58+
59+
it("caps file list at MAX_REPO_FILES when repo is very large", async () => {
60+
vi.mocked(getChangedFiles).mockResolvedValue(new Set());
61+
const bigList = Array.from({ length: 60_000 }, (_, i) => `file${i}.ts`);
62+
vi.mocked(listFiles).mockResolvedValue(bigList);
63+
vi.mocked(listUntrackedFiles).mockResolvedValue([]);
64+
65+
const service = new FsService();
66+
const entries = await service.listRepoFiles("/repo");
67+
68+
expect(entries.length).toBeLessThanOrEqual(50_000);
69+
});
70+
71+
it("omits untracked files when git ls-files --others is aborted", async () => {
72+
vi.mocked(getChangedFiles).mockResolvedValue(new Set());
73+
vi.mocked(listFiles).mockResolvedValue(["tracked.ts"]);
74+
vi.mocked(listUntrackedFiles).mockRejectedValue(new Error("AbortError"));
75+
76+
const service = new FsService();
77+
const entries = await service.listRepoFiles("/repo");
78+
79+
expect(entries.some((e) => e.path === "tracked.ts")).toBe(true);
80+
});
5181
});
5282

5383
describe("FsService repo file IO", () => {

packages/workspace-server/src/services/fs/service.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
11
import fs from "node:fs/promises";
22
import path from "node:path";
3-
import { getChangedFiles, listAllFiles } from "@posthog/git/queries";
3+
import {
4+
getChangedFiles,
5+
listFiles,
6+
listUntrackedFiles,
7+
} from "@posthog/git/queries";
48
import { injectable } from "inversify";
59
import type { BoundedReadResult, DirectoryEntry, FileEntry } from "./schemas";
610

711
@injectable()
812
export class FsService {
913
private static readonly CACHE_TTL = 30000;
1014
private static readonly READ_REPO_FILES_CONCURRENCY = 24;
15+
// Large repos (100k+ files) cause GC pressure that starves the session-init
16+
// event loop. Cap the combined tracked + untracked list to bound allocation.
17+
private static readonly MAX_REPO_FILES = 50_000;
18+
// Abort git ls-files --others if it takes too long (unignored venvs, caches,
19+
// or staticfiles directories can make it scan millions of entries).
20+
private static readonly UNTRACKED_TIMEOUT_MS = 8_000;
1121
private cache = new Map<string, { files: FileEntry[]; timestamp: number }>();
1222

1323
async listDirectory(dirPath: string): Promise<DirectoryEntry[]> {
@@ -43,7 +53,7 @@ export class FsService {
4353
const changedFiles = await getChangedFiles(repoPath);
4454

4555
if (query?.trim()) {
46-
const allFiles = await listAllFiles(repoPath);
56+
const allFiles = await this.fetchAllFiles(repoPath);
4757
const directories = this.deriveDirectories(allFiles);
4858
const lowerQuery = query.toLowerCase();
4959
const matchingDirs = directories.filter((d) =>
@@ -64,7 +74,7 @@ export class FsService {
6474
return limit ? cached.files.slice(0, limit) : cached.files;
6575
}
6676

67-
const files = await listAllFiles(repoPath);
77+
const files = await this.fetchAllFiles(repoPath);
6878
const directories = this.deriveDirectories(files);
6979
const entries = [
7080
...this.toDirectoryEntries(directories),
@@ -221,6 +231,29 @@ export class FsService {
221231
}));
222232
}
223233

234+
private async fetchAllFiles(repoPath: string): Promise<string[]> {
235+
const controller = new AbortController();
236+
const timer = setTimeout(
237+
() => controller.abort(),
238+
FsService.UNTRACKED_TIMEOUT_MS,
239+
);
240+
try {
241+
const [tracked, untracked] = await Promise.all([
242+
listFiles(repoPath),
243+
listUntrackedFiles(repoPath, { abortSignal: controller.signal }).catch(
244+
() => [],
245+
),
246+
]);
247+
const combined = tracked.concat(untracked);
248+
if (combined.length > FsService.MAX_REPO_FILES) {
249+
combined.length = FsService.MAX_REPO_FILES;
250+
}
251+
return combined;
252+
} finally {
253+
clearTimeout(timer);
254+
}
255+
}
256+
224257
private deriveDirectories(files: string[]): string[] {
225258
const dirs = new Set<string>();
226259
for (const file of files) {

0 commit comments

Comments
 (0)