fix(review): bound server memory for large tracked-file diffs - #1167
fix(review): bound server memory for large tracked-file diffs#1167rNoz wants to merge 6 commits into
Conversation
PR backnotprop#1118 renders large untracked files as binary additions, but staging one moves it into the tracked `git diff` path, which had no size guard and buffered the full multi-megabyte patch (~240 MB RSS on a 51 MB text artifact). Any large tracked text file modified in the working tree hits the same unguarded path. Add a per-invocation `git -c core.bigFileThreshold=<MAX_REVIEW_FILE_CONTENT_BYTES>` prefix to every content-producing git diff, so git renders oversized blobs as "Binary files ... differ" instead of a text patch. Their bytes never enter git's diff machinery or the server's buffered stdout, mirroring the untracked-file guard. The flag is a no-op at or below the threshold, so smaller files are byte-for-byte unaffected, and the blob hash git emits in the binary diff still changes with content, so staleness detection holds. The guard is applied in the shared cores, so the Bun and Pi runtimes inherit it identically: `review-core.ts` covers the ordinary git provider (working-tree, staged, commit, and the freshness fingerprint) and `gitbutler-core.ts` covers the GitButler object diff. The jj provider runs `jj diff`, which has no `core.bigFileThreshold` equivalent, so it is out of scope here and stays unbounded as before.
|
Deep review done, with empirical verification against your branch's own code (git 2.50.1, driver script over real repos). The mechanism is sound and well-placed: the core.bigFileThreshold guard is prefixed on all 9 patch-producing diff sites plus the fingerprint, both runtimes flow through the shared function (verified neither has its own tracked-patch diff), vendor.sh and the pi tarball already carry both files, GitButler invariants untouched, and the stub keeps real blob hashes on both sides so staleness detection survives for bounded files. The #1120 repro (git add on a large file) is genuinely fixed: ~100 B stub across staged, uncommitted, and since-base. Renames, whitespace mode, both-sides size changes, committed views, and the exact 5 MiB boundary all verified bounded, and sub-threshold output is byte-identical with and without the guard. One real hole and a few fixes before merge:
Nothing here is a regression against main: every case is bounded or unchanged. The gap is between what the PR claims and what it delivers, and the fix list is small. Happy to re-review on push. |
|
Pushed the review fixes. Equal-size edits, attributes, PR stack diffs, and the exact boundary are covered. Checks are passing. |
|
Re-verified 7775aa0 empirically with the same driver-script approach. The redesign to a #1118-style preflight was the right call and every original finding is genuinely fixed: the equal-size edit now returns ~100-byte stubs on all three working-tree views AND the fingerprint (which uses size+mtime metadata with zero hash-object calls while polling, still flipping on same-size re-edits); gitattributes/textconv bypass is immune by construction and covered by a rewritten test that intercepts every rendered patch and pins the byte bound for real this time; both pr-stack sites are guarded with error messages preserved; boundary pinned at exactly MAX vs MAX+1; assertions are index-based; and the batch design keeps preflight cost O(1) in file count (one cat-file --batch-check, measured +15ms on the fingerprint poll). Adversarial probing of the new mechanism held up against glob-hostile paths including newlines, merge-conflict states, blobless partial clones, and rename/delete/mode stubs, with byte-identical output to main everywhere except genuinely oversized files. Full suite 2797 pass, typecheck clean, pi pack correct. The new mechanism introduces one blocker and two decisions, then this is ready:
Nits, take or leave: hash-object slurps the full oversized worktree file per patch (a core.bigFileThreshold on that one call would make git stream it); stubs append at the end with a different abbreviation width than the rest of the diff; the big-lockfile UX from the first review remains an accepted tradeoff worth a docs line eventually. The rewritten tests deserve a call-out: intercepting rendered patches and asserting the byte bound, running under a textconv attribute, and pinning the exact exclude pathspec argv is exactly how this property should be pinned. Happy to do a final pass on push. |
|
Pushed the final fixes. Gitlinks remain normal pointer diffs, small textconv output is preserved, pure rename and mode-only stubs stay honest, and the browser build boundary is restored. Local review and hook builds pass. |
|
Final verification of 141eb4b, all with the same empirical driver. Everything from the previous round is fixed with directed regression tests:
One ask, fine as a follow-up if you prefer to land now: make getFileInfo/readLink required rather than optional on ReviewGitRuntime. A runtime that omits them compiles today and silently loses the memory bounds (measured: the untracked guard from #1118 disables entirely) plus hunk expansion. Every implementer already has them, so requiring them costs nothing and turns a silent degradation into a compile error, which matters before this seam gets copied by other hosts. Merge-ready from my side. For the record across all three of your PRs today: this is some of the best iterative contribution work this repo has seen, and the redesigns (preflight here, occurrence-binding in #1169, the prefix-sum index in #1168) each ended up structurally better than the review's own suggestions. |
Thank you for your words. Don't forget checking this fix #1154 (I am using in my integration branch on a daily basis, as I run my own plannotator built from that branch). P.S. Working on the optional follow-up. |
Fail compilation when a runtime omits file metadata or symlink support instead of silently disabling bounded reads and expansion.
|
Implemented the optional runtime-contract follow-up: All typed Git and GitButler test runtimes satisfy the contract. Local validation: 157 affected tests, every TypeScript project check, and both review/hook builds pass. CI is running on the new head. |
|
Verified cb06aff: getFileInfo and readLink are required members, the optional-fallback branches are gone (net -13 lines in review-core), and the IsRequired compile-time assertion pins both so a future edit cannot quietly re-optionalize them. Typecheck clean, 1194 shared+server tests pass. Nothing outstanding; fully merge-ready including the follow-up. Thanks for closing the loop on the same day. |
Summary
Fixes #1120.
PR #1118 bounded server memory for large untracked files by rendering anything over
MAX_REVIEW_FILE_CONTENT_BYTES(5 MiB) as a binary addition instead of loading its contents. The tracked working-tree diff path had no equivalent guard, so staging a large file (which moves it out ofgit ls-files --othersand intogit diff HEAD) re-buffered the full multi-megabyte patch. The reporter measured roughly 240 MB RSS aftergit addon a 51 MB text artifact. Any large tracked text file modified in the working tree hits the same unguarded path.This change adds a size preflight in front of every content-producing tracked diff, so an oversized tracked blob is excluded from the real
git diffinvocation and rendered as a bounded stub instead. The oversized contents never enter git's diff machinery or the server's buffered stdout, matching how the untracked path is already bounded.Approach
A raw preflight runs ahead of the diff: it enumerates the tracked paths in scope, batch-queries their object sizes with
git cat-file --batch-check, and literally excludes (via pathspec) any path whose blob exceeds the 5 MiB threshold from the realgit diffinvocation. Each excluded path gets a bounded, diff-parseable stub spliced back in, in the sameBinary files ... differshape git itself emits for binary files (only for paths whose content actually changed, so a pure rename or a mode-only change never gets a spurious stub line), so downstream parsing is unaffected.--no-textconvis scoped to the size preflight itself, not the rendered diff, so a repository using a textconv driver still gets its normal converted diff for files under the size threshold; only a genuinely oversized file falls back to the raw stub. A gitlink (submodule) entry is never treated as an oversized blob, so submodule pointer changes keep rendering as the ordinary subproject-commit summary, and its diff still changes fingerprint on every pointer move.For the freshness fingerprint, oversized worktree files are represented by size and mtime metadata rather than by hashing their content, so the every-few-seconds staleness poll stays cheap and never re-reads the bytes, while still changing when the file is genuinely edited (including an edit that keeps the same byte length). Reading that metadata goes through two required runtime-seam methods (file info and symlink resolution) on the existing per-runtime interface, rather than importing
node:fs/node:pathdirectly into the shared diff core. Making both methods required turns an incomplete host implementation into a compile error instead of silently disabling bounded reads or hunk expansion.This covers the ordinary git provider's working-tree, staged, committed, and empty-tree diffs, the GitButler object diff, both PR-stack diff sites, and the file-content expansion endpoint, all against the same exact boundary (a blob at exactly the 5 MiB limit still renders as text; one byte over renders as a stub).
Scope
The preflight applies to every git-provider diff rather than only the working-tree ones named in the issue, so committed diffs of large tracked files and the empty-tree
allview are bounded too. This covers the ordinary git provider and GitButler. The jj provider issuesjj diff, which has no equivalent config knob, so it stays unbounded as it was before this change; a jj size guard would need its own stat-based approach and is out of scope here.Validation
Compatibility
Behavior-preserving for every file at or below 5 MiB (identical diff bytes), including files under a textconv driver. Oversized tracked files now render as a bounded stub, matching the established treatment of oversized untracked files, so the review UI is consistent across both. Submodule pointer changes are unaffected by the size guard. Host runtimes implementing the internal Git runtime interface must provide file metadata and symlink resolution; both production runtimes already did. No endpoint, prop, or payload changes.
Related work
Builds directly on #1118 (untracked-file bound). It may incidentally help #940 only when a single file in that repo exceeds the threshold; it does not address #940's primary trigger, which is a large file count rather than one oversized file.