Skip to content

fix(api): cap diff-commit's patch, and read git apply's stderr as it streams - #778

Merged
ThomasK33 merged 10 commits into
mainfrom
diff-commit-patch-cap
Nov 8, 2026
Merged

ThomasK33 merged 10 commits into
mainfrom
diff-commit-patch-cap

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Nov 8, 2026 •

Copy link
Copy Markdown
Member

Why

diff-commit capped each decoded chunk at 4 MiB but not their sum. applyPatch kept all of git apply's stderr in memory to classify it (#759), and git apply prints a line per file that fails. So that buffer grew with the patch: with git 2.55.0, a 1,735,560-byte patch of 20,000 files that the base does not have gave 848,890 bytes of stderr. #776 named this as the one runner that keeps all of git's stderr.

What changes

  • A total patch cap. diff-commit takes at most 262144000 bytes of decoded patch, summed over its chunks: the commit-pack limit. Past that, the server stops reading and answers 400 invalid with diff exceeds 262144000 bytes, as it answers a chunk over 4 MiB. Upstream's diff-commit lists 400, 409 and 412, and no 413. No ref moves.

  • git apply's stderr as it streams. wcApplyStderr reads git apply's stderr as git writes it, and keeps only what applyPatch needs:

    • the facts that the status comes from: a line that starts with one of wcApplyPayloadLines (400), with wcApplyClashLine (409) or with fatal: , and whether stderr names the scratch directory or the cached repository (500, as before)
    • the first 64 KiB, from which the client's message comes (cut to 4096 bytes, as before)
    • the last 64 KiB in gitlimit.TailBuffer, which the log's error quotes, as before

    It keeps about 128 KiB of content (a 64 KiB head and a 64 KiB tail) and uses at most about 192 KiB of memory, whatever git prints. A line counts by its first bytes, and a server path that two writes split still counts. wcApplyStatus reads a whole stderr through it, and wcApplyServerFailure, which only applyPatch called, goes.

  • Lines are read as git wrote them. The old code read them after strings.TrimSpace of the whole stderr, which differs only for white space before git's first line, or a last line that ends in a space that a known line head ends with.

  • docs/explanation/streaming-commit-protocols.md and the HTTP API status table state the patch limit.

  • gitlimit.TailBuffer's small writes cost linear time. Each small write past 64 KiB moved the whole kept tail to the front, so 64 MiB of 50-byte writes copied about 87 GB. git writes stderr a message at a time, and a slow git gives one pipe read per line. The buffer now takes up to 2 × 64 KiB, then moves the last 64 KiB to the front once, and it grows its array to at most 2 × 64 KiB. String reads the last 64 KiB, from its first character when more was written.

  • gitlimit.CombinedOutputIn keeps only the last 64 KiB of the output in a TailBuffer, with the same signature. It has no caller, so nothing changes today, but a future caller cannot quote an unbounded output.

  • The lease audit's reason for hydrate.go names RunIn, which updateRefStdin uses since fix: keep only the tail of git's stderr in every git runner's error #776.

Verification

  • TestDiffCommitTotalSizeLimit lowers the cap to one patch's size: one byte over it answers 400 with the message and moves no ref, and exactly at it the commit lands.
  • TestDiffCommitManyFailingFiles sends the 20,000-file patch on every backend: 409 conflict, the message starts with git's first line and is cut to 4096 bytes, and no ref moves.
  • TestApplyStderrKeepsABoundedPart writes that patch's stderr in 4 KiB pieces: about 128 KiB is kept, and the status, the server-failure answer and the message equal the old whole-stderr reading.
  • FuzzApplyStderrChunks feeds a stderr in random pieces and compares each answer with the old whole-stderr reading (kept in the test as the oracle). It ran 2 minutes (26,306 inputs on 4 workers), and FuzzApplyRejection 1 minute (146,364 inputs), with no failure.
  • TestApplyStderrLongLineKeepsItsStart writes a 64 MiB line with no newline: the line buffer holds at most 2 × wcApplyLineKeep bytes, and the status is 400. TestApplyStderrFindsASplitServerPath splits the longest server path at every byte, and writes it a byte at a time.
  • Each of these mutants fails a test: no total cap, no carry of a split server path, a carry one byte short, a carry sized for the first path only, an unbounded line buffer, no payload-line check, no ellipsis, no read of a last line without a newline, a 10-byte line head, and a 100-byte message head.
  • gitlimit tests: TestTailBufferSmallWritesCopyLittle counts the bytes that 4 MiB of 50-byte writes copy (at most twice the bytes written). TestTailBufferFollowsEveryWrite checks the tail after every write of a stream of 4-, 3-, 2- and 1-byte runes. Other tests check the memory after every write, an exact 64 KiB write alone and after one byte, a caller that reuses its array, and, for HeadBuffer, its memory after 64 MiB and a write that crosses the limit with varied bytes. TestCombinedOutputInKeepsTheLast64KiB runs a child that prints 1 MiB. Each of these mutants fails a test: the old quadratic move, a move that does not set the cut flag, a view that does not set it, growth past 2 × 64 KiB, a move one byte short, a view of 64 KiB + 1, the verifier's TailBuffer and HeadBuffer mutants (a cut flag not set on small writes, a strip of at most 2 bytes, a large write that keeps the caller's array, a cut on an exact fit, a large write after earlier bytes without the cut, a HeadBuffer that keeps all or keeps a write's tail), and an unbounded CombinedOutputIn.

Lanes

On this head, with -race for every test run:

  • go build ./..., go vet -tags=openstorage_viewcheck ./..., golangci-lint (0 issues), scripts/check-doc-links.py (0 broken links), and both fingerprint checks (check.sh --fingerprints-only of verify-lane-schedule and verify-orphan-sweep: PASS, no re-record).
  • internal/gitlimit/... passes. internal/api passes on PostgreSQL (with -tags=openstorage_viewcheck), local and S3-WAL.
  • The stderr-tail tests of internal/githttp, internal/jobs, internal/lfsscan, internal/sync and internal/simtest pass, because they read TailBuffer.

Size

683 changed lines: 249 of production Go (204 in internal/api/handlers_write_commit.go, 45 in internal/gitlimit), 426 of tests and 8 of docs.


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: xhigh

…streams

diff-commit capped each decoded chunk at 4 MiB but not their sum, and
applyPatch kept all of git apply's stderr to classify it. git apply
prints a line per file that fails, so that buffer grew with the patch
(850 KB of stderr for a 1.7 MB patch of 20,000 files).

diff-commit now takes at most 262144000 bytes of decoded patch, the
commit-pack limit, and answers 400 invalid past it, as for a chunk over
4 MiB. applyPatch reads stderr with wcApplyStderr, which keeps only the
facts that the status comes from, the head that the client sees and the
tail that the log sees: about 128 KiB, whatever git prints.
wcApplyStatus and wcApplyServerFailure read a whole stderr through it.

Change-Id: I344ac48019953424ec485f3fd139a85feeca331a
Signed-off-by: Thomas Kosiewski <tk@coder.com>
Change-Id: I31c306e28e212d72c9c1eb2cc70617e3033f1078
Signed-off-by: Thomas Kosiewski <tk@coder.com>
…ument the diff cap

The head stands for a longer stderr, so one path builds the message.
docs/explanation/streaming-commit-protocols.md and the HTTP API status
table state diff-commit's 262144000-byte patch limit.

Change-Id: Iaefe7c73e5e4cebfe796c84e25cbdb0289321881
Signed-off-by: Thomas Kosiewski <tk@coder.com>
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-08T07:20:02.226483Z d174004 Manual request
🔒 Security Review ✅ Completed 2026-11-08T07:21:59.110225Z d174004 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 2b53332405

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 2b53332405

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Change-Id: I8a357c561f87f5ca89f520f1f76f49ba8a658992
Signed-off-by: Thomas Kosiewski <tk@coder.com>
Change-Id: I5f9a6e6a747d5cceedfa50c7f337f4c76939eff9
Signed-off-by: Thomas Kosiewski <tk@coder.com>
…me RunIn for hydrate.go

TestTailBufferStartsAtACharacter cut only one continuation byte, so a
String that dropped a single byte passed. A 4-byte-rune case now leaves
three. The lease audit's reason for hydrate.go names RunIn, which
updateRefStdin uses since the stderr-tail change.

Change-Id: I324e027199616c999fd32b89bb7be3bb4f4f4194
Signed-off-by: Thomas Kosiewski <tk@coder.com>
…survived

TailBuffer: a cut that small writes bring, as exec's 32 KiB writes do;
a cut that leaves three continuation bytes; a Write of exactly 64 KiB,
alone and after earlier bytes; and a Write whose array the caller
changes afterwards. HeadBuffer: the memory it holds after 64 MiB, and a
Write that crosses the limit with varied bytes.

Change-Id: Ie4a1e59dbe945da38a0cba4e2dc6766fdcb120ea
Signed-off-by: Thomas Kosiewski <tk@coder.com>
CombinedOutputIn has no caller, but it buffered all of the child's
output, so a caller that quoted it in an error would reopen the
unbounded stderr that TailBuffer closes for the other runners. It keeps
its signature and returns the TailBuffer's tail as written.

Change-Id: I42d7740d57287e674189363149d535abd7d2e444
Signed-off-by: Thomas Kosiewski <tk@coder.com>
Each small write past 64 KiB moved the whole kept tail to the front, so
64 MiB of 50-byte writes copied about 87 GB. The buffer now takes up to
2 x 64 KiB and then moves the last 64 KiB to the front once, and it grows
its array to at most 2 x 64 KiB. String reads the last 64 KiB. Tests pin
the bytes copied, the memory after every write, and the tail after every
write of a mixed-rune stream.

Change-Id: Id2701798c02c04934eb05690d0369f18c43549e2
Signed-off-by: Thomas Kosiewski <tk@coder.com>
A 64 MiB stderr line with no newline keeps only its start for the line
checks. The longest server path counts at every split position of two
writes, and in one-byte writes.

Change-Id: If4df323324e4894b56aa04793ace781c4edc9998
Signed-off-by: Thomas Kosiewski <tk@coder.com>
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: d174004a28

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: d174004a28

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@ThomasK33
ThomasK33 added this pull request to the merge queue Nov 8, 2026
Merged via the queue into main with commit 797dcd5 Nov 8, 2026
12 checks passed
@ThomasK33
ThomasK33 deleted the diff-commit-patch-cap branch November 8, 2026 09:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant