perf(workbench): stream the tracked patch into the content digest - #256
Open
rohanpoudel2 wants to merge 1 commit into
Open
perf(workbench): stream the tracked patch into the content digest#256rohanpoudel2 wants to merge 1 commit into
rohanpoudel2 wants to merge 1 commit into
Conversation
`worktree_content_digest_for_context` read the working-tree patch through `git_bytes`, which is `subprocess.run(..., capture_output=True)`, so the whole `git diff --binary` output was materialised in memory before being hashed. A repository holding a large changed binary could therefore exhaust the workbench during setup inspection, which runs on every inspection rather than only at registration. On a fixture with a 20 MiB incompressible change, Git emitted a 51.5 MiB patch and the digest process peaked at 145.8 MiB RSS. `update_digest_field` frames every value with an 8-byte big-endian length, so the total byte count must be known before any content is hashed and stdout cannot simply be fed into the hash. `git_digest_field` spools Git's stdout straight to a private temporary file, takes the length from `fstat`, writes the same framing, and then hashes the file in 1 MiB chunks. A single Git invocation still produces the patch, so the snapshot stays atomic. The spool file lives in the process temporary directory, never in the scan directory or the scanned repository, is created owner-only, and is removed by the `with` block; on POSIX it is unlinked before Git writes to it, so the patch is never reachable by name. `git_bytes` keeps its buffered behaviour for its many small callers, including the untracked `ls-files` listing in the same function. Digests are unchanged. Old and new code produce identical digests for an empty diff, a text diff, a binary diff, a diff with untracked files, and a 20 MiB binary diff, and a clean worktree still hashes to the hardcoded `clean_worktree_content_digest` sentinel. Peak RSS on the 20 MiB fixture falls from 145.8 MiB to 27.4 MiB.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #249
Problem
worktree_content_digest_for_contextobtained the working-tree patch throughgit_bytes, which issubprocess.run(..., capture_output=True), socompleted.stdoutheld the entiregit diff --binaryoutput before a single byte was hashed. This is the hotter of the two digest paths: it runs on every setup inspection, not only at scan registration.Binary patches are larger than the files they describe, because
git diff --binarybase85-encodes both the forward and the reverse literal. Measured on a fixture with a 20 MiB incompressible change (macOS 15, Python 3.14.5, git 2.50.1), the patch was 51.5 MiB — 2.58x the file — and the digest process peaked at 145.8 MiB RSS, against a 25.2 MiB floor for the same code on a clean worktree.ea19f24contains only the working-tree digest function; there is nocommitted_diff_content_digesthelper in this base (PR #241 is not merged here), so there is one buffering call site to fix, not two.Change
A new
git_digest_fieldnext togit_bytesinsdk/typescript/_bundled_plugin/scripts/workbench_target.py:stdout=to the subprocess, so the patch never passes through Python memory;os.fstaton that file;update_digest_fieldwrites — 4-byte big-endian label length, label, 8-byte big-endian value length — then hashes the file in 1 MiB chunks;git_bytesreturningNone, and leaves the digest untouched when it did not.git_commandgained an optionalstdoutparameter so the streaming path reuses the existing environment scrubbing andcore.fsmonitor=falsehardening rather than building its own command.capture_output=Truebecame the equivalent explicitstdout=PIPE, stderr=PIPE, so every other caller behaves exactly as before.worktree_content_digest_for_contextuses the helper for thetracked-difffield.git_bytesis unchanged and still serves its many small callers, including the untrackedls-files -zlisting in the same function — that output is one NUL-separated path list and does not need spooling.Digest stability
Recorded digests are compared against freshly computed ones when a saved selection is revalidated, so a changed digest would read as changed reviewed content. Old (
ea19f24) and new code were run over the same five fixture repositories, in separate processes, and the digests compared:1d74df0bc5da366e…30e5823fd6690d65…62fc20ee762a9698…7c835ef170583e74…3fe4d8677ae38269…Full digests:
The clean-worktree sentinel is the case a mismatch would break most quietly, so it is checked explicitly.
clean_worktree_content_digest()is a hardcoded value, and the streaming path still reproduces it:Failure behaviour is also unchanged: against a repository with no
HEAD, so thatgit diff HEADfails, both old and new exit 1 withCould not snapshot the selected working-tree changes.tests-ts/workbench-content-digest.test.tspins this permanently rather than relying on a golden hex constant, which would be hostage to the Git version and tocore.autocrlfon Windows. The probe computes each digest twice in one process: once through the streaming helper, and once withgit_digest_fieldreplaced by the bufferedgit_bytesplusupdate_digest_fieldpair it replaced. The two must be equal for a clean worktree, a text diff, a binary diff, and a tree with untracked entries, and the clean worktree must equal the sentinel.Why spool to a temp file and not stream directly
update_digest_fieldwrites an 8-byte big-endian value length before the value, so the total byte count is part of the hashed material and has to be known before any content is hashed. Feedinggit's stdout pipe straight intohashlibis therefore not possible without changing the framing, which would change every digest.The alternative is a second
git diffpass purely to count bytes. That doubles the work and, worse, is not atomic: the working tree can change between the counting pass and the hashing pass, producing a digest that describes no state that ever existed. Spooling keeps one Git invocation and yields the length fromfstat, at the cost of writing the patch to a temporary file.Temporary-file handling, given how strict this codebase is about scan-directory privacy: the spool is a
tempfile.TemporaryFile(), so it lands in the process temporary directory and never in the scan directory or the scanned repository; it is created mode0600; on POSIX it is unlinked before Git writes to it, verified asst_nlink == 0with no directory entry, so the patch is never reachable by name and cannot outlive the process even on a kill; and removal is guaranteed by thewithblock on every path, including the Git-failure return. The test runs each probe withTMPDIRpointed at a private directory and asserts that directory is empty afterwards.Impact, stated plainly
On the 20 MiB fixture the digest process peak RSS drops from 145.8 MiB to 27.4 MiB, a floor set by the interpreter plus the 1 MiB hashing chunk rather than by the patch size. Small fixtures move by about 0.4 MiB, within noise. Git's own peak RSS while producing the patch (165.6 MiB on that fixture) is untouched by this change — it is Git building the binary delta, not the workbench buffering it.
Digest values, digest framing, the Git invocations and their order, and the error message on failure are all unchanged.
git_bytesand its callers are unchanged.Verification
From
sdk/typescript:bun test --timeout 30000 ./tests-ts— 775 pass, 5 skip, 0 fail, 780 tests across 35 files, 39.5 s.tests-ts/workbench-content-digest.test.ts— 2 pass, 31 assertions, 1.6 s. The large-binary case is deliberately 4 MiB rather than 20 MiB: it produces a 10.3 MiB patch, keeps the file under a second, and asserts the peak RSS increase across the digest call (measured 1.6 MiB) stays under half the patch size, which buffering could not satisfy. It is POSIX-only because it needsresource.getrusage.pnpm run types(generate-models --checkandtsc --noEmit) — clean.pnpm run format(prettier --check) — clean.python3 -m py_compile workbench_target.py— clean; generated__pycache__removed.