- ADR-014: the multi-round ack loop, grounded in duplex git 2.43.0 captures cross-checked against fetch-pack.c: no-done rounds get acknowledgments (ACK <oid> per recognized have, NAK when none, flush; never ready so FLUSH is always the terminator), the done round generates closure(wants) - closure(haves) with no cross-round server state (clients re-send wants + commons every round), wait-for-done stays (no capability change), want-less rounds answered empty, the ack check is a new GitPackGen::common_haves seam (honest boundary at the trait), budgets unchanged kinds - docs/research/negotiation-captures.md: the normative negotiation record (grammar, client behavior, malformed-section failure modes) - transport.md: fetch section rewritten to the decided loop; references updated - OQ-02 resolved Verification: cargo test / clippy -D warnings / fmt --check / doc pass
5.5 KiB
Research: V2 multi-round fetch negotiation captures
Status: complete
Date: 2026-09-25
Client: git 2.43.0 (duplex git fetch over git:// against a
ground-truth mock upload-pack that served the negotiation grammar; client
parsing rules confirmed against fetch-pack.c v2.43.0 do_fetch_pack_v2
/ process_ack / send_fetch_request). This is the normative
negotiation record ADR-014 is built on.
Round grammar (definitive, source + captures)
A fetch round request is: command=fetch, caps (agent, object-format),
0001 delim, then args (thin-pack, no-progress, ofs-delta, want lines,
have lines, done), then 0000 flush. Wants are re-sent every round
(add_wants runs unconditionally in send_fetch_request); common haves
are re-sent every round too (add_common — the accumulated common set),
plus new haves from the negotiator (add_haves).
Server response to a round without done:
acknowledgments
[ACK <oid>]* — each have the server recognizes (common)
[NAK] — when no acks in the round (observed: NAK alone)
0000 FLUSH — always, when ready was NOT sent
- The acknowledgments section terminates with FLUSH when no
readyand DELIM (0001) whenreadywas sent (source:process_ack— "a DELIM is expected if ready is sent, and a FLUSH otherwise"). - If
readyis sent, the packfile section follows in the same response (optional shallow-info / wanted-refs / packfile-uris sections first, each DELIM-terminated). We never sendready(see ADR-014), so our section always ends at FLUSH. - Client-side validation (observed by feeding it wrong shapes): acks
section containing
packfile→fatal: unexpected acknowledgment line: 'packfile'; acks + FLUSH afterready→fatal: expected packfile to be sent after 'ready'; acks header missing →expected 'acknowledgments'.
Client round behavior (captures + source)
- Have batching: first round sends up to 16 haves (
INITIAL_FLUSH), doubling per round (32, 64, … 16384 stateless / +32 duplex) — client-side only; it is our budget's ceiling, not our obligation (the ADR-009 haves default should sit at or above 16384 so legitimate negotiations are not clipped). doneis sent when: the negotiator has no more haves to offer (!haves_added), orseen_ack && in_vain >= 256(MAX_IN_VAIN).- Want-less round (captured): a client with everything up-to-date sends
a fetch round with no want lines and exits; the correct server answer
is an empty acknowledgments section (
acknowledgments+NAK+ flush, no pack). The client then exits without a done round. - On receiving ACKs (no ready): client marks commons, sends another round (wants + common haves + new haves). Observed: after 5 ACKs the client re-sent the same 5 as common haves plus continued negotiation.
- On NAK-only: client proceeds to
donequickly when out of new haves (observed: 1 have → NAK → next round want + done). - The
doneround carries wants + common haves + (maybe no new haves) +done. The server must therefore honor haves present in the done request when generating the pack — that is where negotiation pays off. - On the
doneround the response has no acknowledgments section (client goes straight to FETCH_GET_PACK): optional sections thenpackfile+ sideband + flush (POC-1's observed shape, re-confirmed).
ACK line format
ACK <oid> — bare 40-hex oid accepted (process_ack parses ACK +
exactly hexsz chars; suffixes like continue/common/ready after the
oid are the V0 grammar, not required in V2). NAK lines are tolerated
anywhere in the section (continue in the ack loop).
Statelessness
Each POST (stateless http) re-derives cleanly: the client re-sends wants and all common haves every round, so the server needs no cross-request state — each round's haves are the complete common view. Duplex needs no session state either (client re-sends). POC-1/POC-3 substrate facts apply unchanged.
Captures of interest (from mock negotiation runs)
- Round 1
want + 5×have, no done →acknowledgments+ 5×ACK <oid>ready+ DELIM +packfile+ band: client accepted framing and consumed the pack section (failed later only because the mock's pack was empty —did not send all necessary objects, which is the correct client response to a pack missing wanted objects).
- Round 1
want + 1×have, no done →acknowledgments+NAK+ FLUSH → client round 2:want + done(haves remembered server-side are NOT needed; the client trusts the server saw round 1's haves — but the done round's pack correctness does not depend on it when the server generated no pack in round 1: nothing was subtracted yet). - After ACKs without ready (mock bug shapes): client re-negotiates, re-sending wants + common haves. Errors observed for malformed sections confirm the client's section parser is strict.
Implications for alkgit (design, not capture)
- Serve the no-done round with
acknowledgments+ACK <oid>per recognized have +NAKwhen none + FLUSH; neverready(honest underfetch=wait-for-done). - On the done round, generate the pack as closure(wants) minus closure(haves) — the GitPackGen seam already takes (wants, haves, limits), so have-honoring is the negotiation payoff and free at the trait boundary.
- No cross-round server state required (client re-sends wants + common haves every round). Budgets per ADR-009 (rounds cap, haves cap).