| added docs/FR-large-push-400.md |
| + | # FR: pushes over ~1 MiB fail with an undiagnosable `400 bad request` |
| + | |
| + | Status: open — root cause narrowed, not yet identified |
| + | Filed: 2026-09-19 |
| + | Observed on: `git.cosmocr.at` (deployed), pushing `rpg137@gmail.com/einkdashboards.git` |
| + | Reproduced on: `git@cosmocr.at/demo.git` with a 3 MiB payload (see below) |
| + | |
| + | ## Symptom |
| + | |
| + | Pushing a repository whose pack exceeds git's default `http.postBuffer` (1 MiB) |
| + | fails: |
| + | |
| + | ``` |
| + | $ git push --force origin master multi-dashboard |
| + | error: RPC failed; HTTP 400 curl 22 The requested URL returned error: 400 |
| + | fatal: the remote end hung up unexpectedly |
| + | Everything up-to-date |
| + | ``` |
| + | |
| + | `git fetch` against the same repo, over the same connection, with the same |
| + | credentials, succeeds immediately beforehand — so this is not auth, TLS, |
| + | routing, or repo existence. |
| + | |
| + | The workaround is client-side: |
| + | |
| + | ```bash |
| + | git config http.postBuffer 524288000 # 500 MiB |
| + | ``` |
| + | |
| + | After which the identical push succeeds. Every user has to discover and apply |
| + | this per-repo, and nothing in the server's response hints at it. |
| + | |
| + | ## What is actually wrong |
| + | |
| + | **The primary defect is that the failure is unattributable.** In |
| + | `internal/server/githttp.go:140-143`: |
| + | |
| + | ```go |
| + | case gitReceivePack: |
| + | req, err := githttp.DecodeReceivePack(r) |
| + | if err != nil { |
| + | http.Error(w, "400 bad request", http.StatusBadRequest) |
| + | return |
| + | } |
| + | ``` |
| + | |
| + | Every possible decode failure — malformed pkt-line, truncated body, a pack |
| + | go-git cannot resolve, a body over `maxBody`, a read error mid-stream — |
| + | collapses into the same opaque string, and **`err` is discarded without being |
| + | logged**. Every other branch in this handler logs (`log.Printf("gitweb: |
| + | upload-pack %s: %v", ...)`); this one does not. |
| + | |
| + | Consequence: the server operator cannot tell a client-side protocol error from |
| + | a server-side bug, and the client is told nothing actionable. Diagnosing the |
| + | incident above took roughly an hour and still did not reach a root cause. |
| + | |
| + | ## What has been ruled out |
| + | |
| + | Investigated and eliminated, so nobody repeats the work: |
| + | |
| + | | Hypothesis | Verdict | |
| + | |---|---| |
| + | | nginx rejecting the body | **No.** nginx does SNI passthrough (`docs/ARCHITECTURE.md:53`); it routes TCP and never parses HTTP, so `client_max_body_size` cannot apply. | |
| + | | `maxBody` cap in `requestBody` | **No.** 256 MiB (`internal/githttp/githttp.go:34`); the failing pack was ~5.7 MiB. | |
| + | | `MaxRepoBytes` | **No.** 256 MiB default (`main.go:57`), and it is enforced after decode. | |
| + | | Server read timeout cutting off a slow upload | **No.** Only `ReadHeaderTimeout` is set (`main.go:112`); there is no `ReadTimeout`. | |
| + | | **Chunked transfer-encoding decoding** | **No — tested.** `DecodeReceivePack` handles a chunked body correctly, at both minimal size and 6 MiB. See the test below. | |
| + | |
| + | That last row matters: the obvious theory ("gitweb can't parse chunked |
| + | requests") is **false**, and an FR that asserted it would have sent the fix in |
| + | the wrong direction. The chunked path decodes; something else about a real |
| + | large push does not. |
| + | |
| + | ## Controlled reproduction (2026-09-19) |
| + | |
| + | Reproduced on `git@cosmocr.at/demo.git` — a 688 KiB repo unrelated to the |
| + | original incident — using two branches identical except for ancestry, each |
| + | carrying a 3 MiB incompressible payload (random data; zeros would deflate away |
| + | and never exceed `postBuffer` on the wire). |
| + | |
| + | | Branch | Common ancestor with `main` | Pack | `http.postBuffer` | Result | |
| + | |---|---|---|---|---| |
| + | | `size-only` | **yes** | 3.00 MiB | default (1 MiB) | **HTTP 400** | |
| + | | `no-ancestor` (orphan) | no | 3.00 MiB | default (1 MiB) | **HTTP 400** | |
| + | | `size-only` | yes | 3.00 MiB | 500 MiB | **succeeded** | |
| + | |
| + | Client error is byte-identical to the original incident: |
| + | |
| + | ``` |
| + | error: RPC failed; HTTP 400 curl 22 The requested URL returned error: 400 |
| + | fatal: the remote end hung up unexpectedly |
| + | ``` |
| + | |
| + | **Conclusion: pack size alone is sufficient.** The `size-only` branch shares an |
| + | ancestor with `main`, so no external delta bases and no thin-pack resolution |
| + | are involved, and it fails anyway. Ancestry makes no difference — both shapes |
| + | fail identically, and the same objects push cleanly once the client stops using |
| + | chunked encoding. |
| + | |
| + | This demotes the thin-pack hypothesis below. The trigger is a pack exceeding |
| + | `http.postBuffer`, and the fix belongs in request handling rather than the pack |
| + | resolution path. |
| + | |
| + | Also observed in the same session: a **force-push rewinding `main` succeeded** |
| + | at the default `postBuffer` (it sends a ref update and no new objects). Ref |
| + | update handling is therefore not implicated; only the large-pack path fails. |
| + | |
| + | ## Remaining hypotheses |
| + | |
| + | In rough order of likelihood, after the reproduction above: |
| + | |
| + | 1. ~~**Thin packs.**~~ **Ruled out** by the controlled reproduction — a push |
| + | sharing an ancestor, with no external delta bases, fails identically. |
| + | 2. **`Expect: 100-continue` handling.** Now the leading suspect. curl adds the |
| + | header for large streamed bodies; Go sends `100 Continue` only when the |
| + | handler first reads the body, so any path that writes a response before |
| + | reading would strand the client. This fits the evidence: it is specific to |
| + | the large/streamed request path and independent of pack contents. |
| + | 3. **Deployed binary drift.** `HEAD` here is `5444040` (2026-07-12) on branch |
| + | `sso-browser-login`. Whether the running service matches this source is |
| + | unconfirmed — the bug may already be fixed in tree. Worth eliminating |
| + | cheaply before any code change, since the unit tests in this repo show the |
| + | decode path handling chunked bodies correctly. |
| + | |
| + | ## Proposed change |
| + | |
| + | ### 1. Log the error (do this first, independently of any fix) |
| + | |
| + | ```go |
| + | req, err := githttp.DecodeReceivePack(r) |
| + | if err != nil { |
| + | log.Printf("gitweb: receive-pack decode %s: %v", repoPath, err) |
| + | http.Error(w, "400 bad request", http.StatusBadRequest) |
| + | return |
| + | } |
| + | ``` |
| + | |
| + | One line. It converts this class of incident from guesswork into a log grep, |
| + | and it is what tells us which of the hypotheses above is real. |
| + | |
| + | ### 2. Return a useful body to the client |
| + | |
| + | git surfaces the response body on `remote:` lines. Distinguish the cases that |
| + | have distinct remedies — at minimum, separate "body too large" (413, with the |
| + | limit named) from "malformed request" (400). A client seeing |
| + | `remote: pack exceeds 256 MiB limit` needs no server log to self-serve. |
| + | |
| + | ### 3. Fix the underlying failure |
| + | |
| + | Only once (1) identifies it. If it is thin packs, the fix belongs in the pack |
| + | resolution path, not in request handling — and no amount of `postBuffer` tuning |
| + | is the right answer for users. |
| + | |
| + | ## How to test |
| + | |
| + | ### Regression test for the chunked path (passes today — keep it that way) |
| + | |
| + | Add to `internal/githttp/githttp_test.go`. This is the test that disproved the |
| + | original theory; it is worth keeping so the chunked path does not silently |
| + | regress: |
| + | |
| + | ```go |
| + | // TestDecodeReceivePackChunked drives a chunked body through a real |
| + | // http.Server, which is what git sends once a request exceeds |
| + | // http.postBuffer (default 1 MiB). |
| + | func TestDecodeReceivePackChunked(t *testing.T) { |
| + | const ( |
| + | oldOID = "0000000000000000000000000000000000000000" |
| + | newOID = "1111111111111111111111111111111111111111" |
| + | ) |
| + | cmd := fmt.Sprintf("%s %s refs/heads/master\x00report-status\n", oldOID, newOID) |
| + | var body bytes.Buffer |
| + | fmt.Fprintf(&body, "%04x%s", len(cmd)+4, cmd) |
| + | body.WriteString("0000") |
| + | body.WriteString("PACK") |
| + | body.Write([]byte{0, 0, 0, 2, 0, 0, 0, 0}) |
| + | body.Write(bytes.Repeat([]byte{0xAB}, 6<<20)) // past postBuffer |
| + | body.Write(make([]byte, 20)) |
| + | |
| + | errCh := make(chan error, 1) |
| + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + | if r.ContentLength != -1 { |
| + | t.Errorf("ContentLength = %d, want -1 (chunked)", r.ContentLength) |
| + | } |
| + | req, err := DecodeReceivePack(r) |
| + | if err == nil && req.Packfile != nil { |
| + | _, err = io.Copy(io.Discard, req.Packfile) |
| + | } |
| + | errCh <- err |
| + | })) |
| + | defer srv.Close() |
| + | |
| + | hreq, _ := http.NewRequest(http.MethodPost, srv.URL+"/git-receive-pack", |
| + | io.NopCloser(bytes.NewReader(body.Bytes()))) |
| + | hreq.ContentLength = -1 // force chunked |
| + | resp, err := http.DefaultClient.Do(hreq) |
| + | if err != nil { |
| + | t.Fatalf("request failed: %v", err) |
| + | } |
| + | defer resp.Body.Close() |
| + | if err := <-errCh; err != nil { |
| + | t.Fatalf("chunked body rejected: %v", err) |
| + | } |
| + | } |
| + | ``` |
| + | |
| + | Run: `go test ./internal/githttp/ -run Chunked -v` |
| + | |
| + | ### End-to-end reproduction (the one that actually fails) |
| + | |
| + | A unit test cannot produce a real git pack. This recipe reproduces it on the |
| + | demo repo in under a minute; it is the one used for the results above. |
| + | |
| + | ```bash |
| + | git clone https://git.cosmocr.at/git@cosmocr.at/demo.git /tmp/repro |
| + | cd /tmp/repro |
| + | |
| + | # Push to a scratch ref with an EXPLICIT refspec. This clone ships |
| + | # push.default=upstream, so a bare `git push origin <branch>` targets the |
| + | # branch's upstream (main) rather than creating a same-named branch — |
| + | # that mistake polluted main once already. |
| + | git checkout -b fr-repro |
| + | |
| + | # Incompressible payload: zeros would deflate away and never exceed |
| + | # postBuffer on the wire. |
| + | mkdir -p fr-repro && head -c 3145728 /dev/urandom > fr-repro/payload-3mb.bin |
| + | git add fr-repro && git commit -qm "FR-large-push-400 repro payload" |
| + | |
| + | git config --unset http.postBuffer 2>/dev/null # ensure the 1 MiB default |
| + | |
| + | # Expected: HTTP 400. |
| + | GIT_CURL_VERBOSE=1 git push origin fr-repro:refs/heads/fr-repro 2>&1 | tee /tmp/push.log |
| + | |
| + | # With the logging change in place, the server log now names the cause: |
| + | sudo docker logs --tail 50 patrick_gitweb_1 | grep 'receive-pack decode' |
| + | |
| + | # Control: identical objects, non-chunked request. Expected: success. |
| + | git -c http.postBuffer=524288000 push origin fr-repro:refs/heads/fr-repro |
| + | |
| + | # Clean up the scratch ref. |
| + | git push origin :refs/heads/fr-repro |
| + | ``` |
| + | |
| + | To also confirm ancestry is irrelevant, repeat with `git checkout --orphan` |
| + | instead of `git checkout -b`; both shapes fail identically. |
| + | |
| + | `GIT_CURL_VERBOSE=1` shows whether the request went out chunked and whether |
| + | `Expect: 100-continue` was used — which settles hypothesis 2 directly. |
| + | |
| + | ### Acceptance criteria |
| + | |
| + | - A failing push writes a specific, actionable reason to the server log. |
| + | - The client receives a response body distinguishing "too large" from |
| + | "malformed". |
| + | - A >1 MiB push succeeds at the default `http.postBuffer`, with no client-side |
| + | configuration, for both normal and orphan history. |
| + | - `go test ./...` green, chunked regression test included. |
| + | |
| + | ## Impact |
| + | |
| + | Low frequency, high friction. It only bites repos over ~1 MiB, but when it does |
| + | the failure is opaque, the fix is non-obvious, and the workaround has to be |
| + | repeated per-repo per-clone. Of the repos currently hosted, `einkdashboards` |
| + | (5.7 MiB) trips it; `mtadash` (328 KiB) does not, which is why this went |
| + | unnoticed until now. |