perf(receive-pack): drop the throttled-SHA cheat — feed absorb-only, flush verifies #28

merged colechristensen cole.christensen@gmail.com wants to merge receive-pack-no-cheat into main
No CI

Honest replacement for the 4 MB throttled SHA-1 check landed in #27. Per docs/PERFORMANCE.md, that throttle was the scheduling-around-the-cost cheat — same big-O as the original O(N²) bug, just a bigger denominator.

What changed

feed/2 is now absorb-only: appends bytes to an iolist body, maintains a 20-byte lookahead, never verifies, never decides completeness, never transitions out of :pack. Per-chunk cost: O(1) amortized. Total streaming cost: O(N).

flush/1 is the explicit end-of-stream signal. It hands the materialized buffer to Pack.Reader.parse/2 once. That parse pass already runs the SHA-1 trailer verification — so the previous ReceivePack-level check was both redundant AND quadratic. Total verify work: one O(N) hash + one parse, period.

Errors (corrupted trailer, truncated body, malformed entries) now flow through Pack.Reader’s {:error, reason} and surface as a proper unpack <reason> line in report-status. State transitions to :done so the channel doesn’t hang on a closed connection.

Caller contract change

feed/2 no longer auto-finalizes. Every transport must call flush at end-of-stream:

  • SSH (Anvil.SSH.CLI) — already wired in anvil#127’s {:eof, channel_id} handler.
  • HTTP (AnvilWeb.GitHttpController.receive_pack) — needs flush after read_full_body. Bundled with the mix.lock bump in the companion anvil PR.
  • Test transportsgit_daemon.ex flushes on socket close; the LFS HTTP adapter flushes after one-shot body read. Both updated here.

Test plan

  • 928/0 in the full ex_git_objectstore suite.
  • All test files using direct ReceivePack.feed updated to follow the new feed-then-flush contract.
  • New assertions in protocol_interop_test pin the corrupted-trailer + truncated-pack behavior: state transitions to :done with {:error, _} and reports a structured unpack failure, rather than sitting in :pack until the channel closes.
  • mix format --check-formatted clean.
  • mix dialyzer clean.

Memory note

Pack body is still fully buffered in memory until flush. Tracked in anvil#153 — the path forward is a streaming Pack.Reader that consumes bytes and emits parsed objects incrementally. For ovs (~106 MB pack) full-buffer fits comfortably under the 3.82 GiB container cap.

Created May 06, 2026 at 05:19 UTC | Merged May 06, 2026 at 05:24 UTC by colechristensen cole.christensen@gmail.com