Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

fix(fs): cap file list and abort untracked scan to prevent session init timeout on large repos - #3408

Closed
charlesvien wants to merge 4 commits into
mainfrom
fix/session-init-timeout
Closed

charlesvien wants to merge 4 commits into
mainfrom
fix/session-init-timeout

Conversation

@charlesvien

Copy link
Copy Markdown
Member

Problem

Session initialization times out after 30s on large repos (100k+ tracked files or big untracked trees like __pycache__ or venv). FsService.listRepoFiles scans untracked files unbounded during startup, which can allocate hundreds of MB on the workspace-server event loop and starve the init IPC promise past its deadline.

Same change as #2819 by @ricardo-leiva, resubmitted from an internal branch because the Trunk merge queue cannot process fork PRs. His commits are preserved here.

Changes

listAllFiles in packages/workspace-server/src/services/fs/service.ts now runs the tracked and untracked scans in parallel, aborts the untracked scan after 8s (falling back to tracked files only) and caps the combined list at 50k entries before building the directory tree.

How did you test this?

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

ricardo-leiva and others added 4 commits July 12, 2026 22:00
…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
… review

- Use toBe(50_000) for flat input — zero derived directories means total === cap
- Add test documenting that entries total can exceed 50k when nested paths
  produce extra directory entries via deriveDirectories (cap is on raw files,
  not on the final files+dirs result)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpGjPYBD4pgZpsAHjozzsN
@trunk-io

trunk-io Bot commented Jul 13, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@github-actions

Copy link
Copy Markdown

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit 2a3ccb0.

@greptile-apps

greptile-apps Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "consolidate file-list cap and timeout in..." | Re-trigger Greptile

Comment on lines +1202 to +1206
listFiles(baseDir, { abortSignal: controller?.signal }).catch(
(): string[] => [],
),
listUntrackedFiles(baseDir, { abortSignal: controller?.signal }).catch(
(): string[] => [],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Shared Timeout Hides Files

When the 8s timer fires while both git commands are still running, the shared abort signal cancels the tracked scan as well as the untracked scan. Both catches then return empty arrays, so listRepoFiles can cache and return an empty repo tree instead of the intended tracked-file fallback.

Comment on lines +1209 to +1212
const combined = untracked.concat(tracked);
if (maxFiles !== undefined && combined.length > maxFiles) {
combined.splice(maxFiles);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Untracked Files Consume Cap

In a repo with at least 50k untracked generated files, untracked.concat(tracked) fills the cap before any tracked source file is kept. The workspace file list and query path then omit tracked files like package.json even when the tracked scan succeeded.

Suggested change
const combined = untracked.concat(tracked);
if (maxFiles !== undefined && combined.length > maxFiles) {
combined.splice(maxFiles);
}
const combined = tracked.concat(untracked);
if (maxFiles !== undefined && combined.length > maxFiles) {
combined.splice(maxFiles);
}

@greptile-apps

greptile-apps Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Reviews (2): Last reviewed commit: "consolidate file-list cap and timeout in..." | Re-trigger Greptile

Comment on lines +1201 to +1208
const [tracked, untracked] = await Promise.all([
listFiles(baseDir, { abortSignal: controller?.signal }).catch(
(): string[] => [],
),
listUntrackedFiles(baseDir, { abortSignal: controller?.signal }).catch(
(): string[] => [],
),
]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Shared timeout aborts tracked files When FsService passes the 8s timeout, this code sends the same abort signal to both git ls-files calls. In a large or cold repo where the tracked scan is still running when the timer fires, both commands reject and both catches return empty arrays. The no-query listRepoFiles path then caches an empty tree for 30 seconds, so the workspace can show no files instead of falling back to tracked files. The timeout should only degrade the untracked scan, or tracked files should be allowed to complete independently.

Suggested change
const [tracked, untracked] = await Promise.all([
listFiles(baseDir, { abortSignal: controller?.signal }).catch(
(): string[] => [],
),
listUntrackedFiles(baseDir, { abortSignal: controller?.signal }).catch(
(): string[] => [],
),
]);
const [tracked, untracked] = await Promise.all([
listFiles(baseDir).catch((): string[] => []),
listUntrackedFiles(baseDir, { abortSignal: controller?.signal }).catch(
(): string[] => [],
),
]);

Comment on lines +1209 to +1212
const combined = untracked.concat(tracked);
if (maxFiles !== undefined && combined.length > maxFiles) {
combined.splice(maxFiles);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Untracked files consume cap This builds the capped list with untracked files first. In a repo with 50k or more untracked/generated files, the splice keeps only those entries and drops every tracked source file even when the tracked scan succeeded. listRepoFiles uses this capped list directly for the file tree and query filtering, so committed files such as package.json or source modules can disappear from the workspace browser and search. Tracked files should be kept first, with untracked files filling any remaining budget.

Suggested change
const combined = untracked.concat(tracked);
if (maxFiles !== undefined && combined.length > maxFiles) {
combined.splice(maxFiles);
}
const combined = tracked.concat(untracked);
if (maxFiles !== undefined && combined.length > maxFiles) {
combined.splice(maxFiles);
}

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants