Stream large -z git output instead of buffering it against the cap - #2045
Tyrie Vella (tyrielv) merged 2 commits into
Conversation
c0cca88 to
e4198f0
Compare
4b8cc4a to
1a2dc13
Compare
1a2dc13 to
9328962
Compare
703e798 to
5cfb8df
Compare
5cfb8df to
7428a5a
Compare
|
The streaming rewrite and the deadlock reasoning look sound to me — one sync/one async read, stderr drained via BeginErrorReadLine, watchdog kill guarded under processLock/readCompleted. I have one robustness concern I'd like addressed before final approval. After Suggestions:
Net: the logic is good; the only real gap is that the final wait is the one place with neither a timeout nor the watchdog covering it. A bounded wait + re-kill closes it. |
7428a5a to
2d68c92
Compare
Building on the git-output bounding change, this adds an opt-in path that removes
the truncation exposure for the two commands whose full result is
correctness-critical by streaming their output instead of buffering it.
DiffCachedNameStatus (diff --cached --name-status -z) feeds every staged path
into ModifiedPaths; StatusPorcelain (status --porcelain -z) drives the sparse
dirty check. Both use -z (NUL-delimited), so line-based streaming cannot chunk
them - git delivers the whole blob as a single line.
Add a NUL-delimited streaming mode to InvokeGitImpl: a new parseStdOutToken
callback reads stdout synchronously and splits on NUL, invoking the callback
once per record as it arrives. stderr stays async (BeginErrorReadLine), so the
synchronous stdout read cannot deadlock. Only one record is held in memory, so
an arbitrarily large result streams without buffering, truncation, or OOM.
The staged-file callback collects the parsed (status, path) records and applies
them to ModifiedPaths only after git exits successfully, so a mid-stream failure
never leaves a partial ModifiedPaths mutation behind. Holding the parsed records
as many small strings still avoids the single large-array allocation that caused
the OOM.
Both commands expose a streaming overload and a buffered overload. The callers
choose at runtime from the gvfs.stream-git-status-output config key, which
defaults to false (off) per the feature-flag convention: by default they use the
bounded-buffer path and its OutputTruncated fail-safes (the proven behavior), and
streaming is enabled only when the rollout infrastructure turns the flag on.
Add an optional streaming watchdog gated by gvfs.git-status-stream-timeout-seconds
(default -1 = infinite/disabled): when set, a timer kills the git process tree if
the synchronous read does not finish in time and the result reports a timeout.
The default is infinite so a legitimately long status on a very large working
tree is never killed. The watchdog disarms under processLock once the read
completes, so a late callback cannot report a false timeout or kill a reused
process.
Hardening from self-review: the streaming read kills the git child if a callback
throws (no orphaned process); AddStagedFilesToModifiedPaths fails on an unpaired
trailing status token rather than acting on an incomplete list; MockGitProcess
feeds output through the production tokenizer so the test double cannot drift.
The post-EOF wait is bounded: after stdout closes, the streaming path waits for
git to exit with a finite grace (the caller's timeout when set, otherwise a
generous ceiling) rather than an unbounded parameterless WaitForExit. If git has
not exited by then - a wedged process, or a timeout tree-kill that only partially
succeeded - it kills the tree again and returns a distinct failure instead of
pinning the calling thread. Once exit is confirmed it still calls the
parameterless WaitForExit so the async stderr readers flush before Errors is read.
The bounded wait goes through a shared WaitForExitWithCancellation helper: with no
CancellationToken it is a single WaitForExit(timeoutMs); with one it polls so a
cancellation (e.g. mount shutdown) is observed promptly. The helper is written so
the buffered/credential paths can share the same idiom rather than each rolling
their own bounded wait.
Tests:
- ReadStdOutTokens: NUL splitting, empty input, embedded empty records, a lone
NUL, a trailing record without a NUL, and a record spanning the 8KB buffer.
- Streaming and buffered overloads of DiffCachedNameStatus/StatusPorcelain.
- GetNextGitPath (buffered fallback) parsing; PathCoveredBySparseFolders
unchanged.
Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2d68c92 to
10dfad5
Compare
|
Great catch — that final wait was the one spot with neither a timeout nor the watchdog covering it. Fixed in the latest push (
On the "reuse vs. reimplement" point: the bounded wait now goes through a shared |
Nathan Baird (ShiningMassXAcc)
left a comment
There was a problem hiding this comment.
Approving — both outstanding asks are addressed, and I verified them in the code at 10dfad58 rather than just on the replies.
My ask (bounded post-EOF wait) — closed. All four points landed:
- The post-EOF wait is bounded:
WaitForExitWithCancellation(postReadGraceMs, out _), and on expiry it re-kills the process tree and returns a failureResultinstead of trusting a partial exit code. "Never hang indefinitely" now holds on the stream path as well as the buffer path. - The parameterless
WaitForExit()is still called once exit is confirmed, so the async stderr readers are guaranteed flushed beforeErrorsis read. That property is preserved. postReadGraceMs = timeoutMs != Timeout.Infinite ? timeoutMs : DefaultPostReadGraceMs(60s) — an opted-in bound is honored end-to-end.- The distinct message is genuinely measurable:
FileSystemCallbacksputsresult.ErrorsintoEventMetadataon the failure path, so"GitProcess streaming read: git did not exit within ...ms after stdout closed"reaches the tracer. GivenGitProcesshas no tracer of its own, threading one through just for this isn't worth it — agreed with the call.
The WaitForExitWithCancellation helper reads well as a shared idiom for the credential-bounding work to pick up.
Keith's ask (don't mutate ModifiedPaths before git diff succeeds) — closed. The streaming callback now only accumulates into pendingRecords; handleRecord runs over them solely under if (result.ExitCodeIsSuccess). A mid-stream failure can't leave a partial mutation behind, and the unpaired-trailing-status check is a nice addition — failing on a truncated-at-the-boundary stream rather than silently dropping the last entry is the right direction. handleRecord being shared by both the streaming and buffered paths means the two can't drift per record.
SparseVerb is fail-safe too: the streaming path mutates dirtyPathsNotInSparseSet as it goes, but a failure returns false and the caller reports statusResult.Errors and aborts, so the partial set is never acted on. The Clear() at the top of the delegate covers a ShowStatusWhileRunning retry.
Verified locally at 10dfad58 (build clean, 0 warnings): full unit suite 1010 tests, 1005 passed. Every test touching this change passes — the six ReadStdOutTokens_* cases, DiffCachedNameStatus_StreamsRecordsAsTokens, StatusPorcelain_StreamsRecordsAsTokens, both *_BufferedFallbackReturnsOutput fallbacks, and the PathCoveredBySparseFolders_* set. The 5 failures are PostIndexChangedHookTests (SkipsNotification_*), which fail identically on origin/vnext without this PR — pre-existing and unrelated.
Two non-blocking notes for whenever this next gets touched:
- The PR description says the
-zstring parsers (GetPathsNotCoveredBySparseFolders,GetNextGitPath) were removed and theGetNextGitPathtest replaced. They're still present, correctly — the buffered fallback behindgvfs.stream-git-status-outputneeds them, andGetNextGitPathGetsPathsstill passes. Just worth refreshing the description so it matches the flagged shape before merge. WaitForExitWithCancellation'scancellationToken/cancellationRequestedare unexercised at every current call site. That's deliberate groundwork, so fine — but it's dead until something passes a token, and it'd be good for the credential work to actually consume it rather than leave it indefinitely unused.
Defaulting the flag to false and gating the runtime entry point is exactly the right rollout shape. Signing off.
This stacked change uses the shared process-wait helper from microsoft#2045. Pass cancellation through authentication and credential-helper operations. Stop the helper process tree when cancellation occurs. Release HTTP capacity before credential rejection when the off-by-default feature flag is enabled. Prevent duplicate permit releases. Preserve terminal credential timeouts across endpoint selection. Prevent concurrent helpers after a credential-gate timeout. Publish connection configuration atomically and emit rollout telemetry. Add tests for contention, cancellation, timeout propagation, and gate reuse. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This stacked change uses the shared process-wait helper from microsoft#2045. Pass cancellation through authentication and credential-helper operations. Stop the helper process tree when cancellation occurs. Release HTTP capacity before credential rejection when the off-by-default feature flag is enabled. Prevent duplicate permit releases. Preserve terminal credential timeouts across endpoint selection. Prevent concurrent helpers after a credential-gate timeout. Publish connection configuration atomically and emit rollout telemetry. Add tests for contention, cancellation, timeout propagation, and gate reuse. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
Follow-up to #2048. That PR bounded the previously-unbounded git stdout/stderr capture and, for the two commands whose full result is correctness-critical, added a large stdout cap plus fail-safe valves if the cap was ever exceeded. This PR removes that truncation exposure entirely by streaming those commands' output instead of buffering it.
DiffCachedNameStatus(diff --cached --name-status -z) feeds every staged path intoModifiedPaths.StatusPorcelain(status --porcelain -z) drives the sparse dirty check.Both use
-z(NUL-delimited), so line-based streaming can't chunk them — git delivers the whole blob as a single "line."Change
Add a NUL-delimited streaming mode to
InvokeGitImpl: a newparseStdOutTokencallback reads stdout synchronously and splits on\0, invoking the callback once per record as it arrives. stderr stays async (BeginErrorReadLine), so the synchronous stdout read is deadlock-safe ("one sync, one async"). Only one record is held in memory, so an arbitrarily large result streams with no buffering, truncation, or OOM. It keeps git's robust-zformat (no path-unquoting) and is guarded totimeoutMs == -1and mutual exclusion withparseStdOutLine.DiffCachedNameStatusandStatusPorcelainnow stream tokens; their callers drive small status/path state machines. This removes the interim fail-safe valves from #2048 (there's no longer a partial result to guard against) and the dead-zstring parsers (GetPathsNotCoveredBySparseFolders,GetNextGitPath).Result.OutputTruncated/ErrorsTruncatedremain for the still-buffered commands (stderr is still bounded), but the two converted commands can no longer tripOutputTruncated.Tests
ReadStdOutTokens: NUL splitting, empty input, embedded empty records, a trailing record without a NUL, and a record spanning the 8KB read buffer.DiffCachedNameStatus/StatusPorcelainstream records through the mock.GetNextGitPathtest;PathCoveredBySparseFolderstests unchanged.Full unit suite: 889 passed, 0 failed (11 pre-existing native-hook skips).