Skip to content

fix(git-remote): drive multi-round fetch as a v0 stateless-RPC client loop (#117)#192

Open
beardthelion wants to merge 14 commits into
mainfrom
fix/issue-117-multiround-fetch-deadlock
Open

fix(git-remote): drive multi-round fetch as a v0 stateless-RPC client loop (#117)#192
beardthelion wants to merge 14 commits into
mainfrom
fix/issue-117-multiround-fetch-deadlock

Conversation

@beardthelion

@beardthelion beardthelion commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Fixes #117.

git-remote-gitlawb advertised the connect capability, so git spoke the stateful native protocol, but handle_connect collapsed the exchange into a single GET + single POST. A multi-round fetch (more than ~32 commits of overlapping history, the common "pull into an existing clone") deadlocked: git sent a flush-terminated have batch and blocked for an ACK/NAK the helper never sent, while the helper blocked reading for a done git never sent.

Phase 2 for git-upload-pack is now a per-round v0 smart-HTTP stateless-RPC client loop, the role git's own remote-curl plays. read_upload_pack_round returns one negotiation round at a time (at a flush, done, or EOF) instead of buffering past flushes; negotiate_upload_pack captures the wants once and POSTs a self-contained request per flush-terminated batch (wants + one flush + every have accumulated so far + exactly one terminator), streams each ACK/NAK back to git, and stops on the done round. The node's git upload-pack --stateless-rpc keeps no state between POSTs, so wants and all prior haves are re-sent every round and no intermediate flush survives into a body.

Signing is preserved per round: every POST carries the Phase-1 decision (signed after the 404 escalation for a private repo, anonymous for a public one), and a mid-negotiation denial surfaces through the sanitized error path rather than reading as an empty or successful fetch. The git-receive-pack (push) path is unchanged, and no node code is touched.

Verification

  • Unit + mock coverage: the deadlock repro (blocking reader, was RED at the 2s timeout), multi-round accumulation asserted byte-for-byte on the wire, per-round signing for private and public fetches (including the must-not cases), mid-negotiation denial surfacing, single-round non-regression, and the branch-coverage edges. Load-bearing checks confirmed by mutation.
  • A committed real-git integration test (tests/real_git_fetch.rs) drives a real git fetch (forced to >=2 rounds) through the built helper against a real git upload-pack --stateless-rpc, so the stateful-to-stateless bridging is executed, not reasoned.
  • Verified against a running node: an incremental fetch with a real two-round negotiation exits 0 with the correct object graph (the exact case that hung before), and a private repo fetched anonymously surfaces the sanitized 404 promptly with no hang and no leak.

Follow-up

The one case this does not make work is an incremental fetch of a repo with a path-scoped withheld subtree: the node's upload_pack_excluding sends its pack on the first POST, which real git rejects mid-negotiation. The helper forwards and terminates cleanly, so it is a node-side issue, filed as #191. A committed test here (real_git_withheld_shaped_first_post) records that rejection so a node-side fix can verify against it.

STRATEGY track: stabilize.

Summary by CodeRabbit

  • Bug Fixes
    • Improved multi-round git-upload-pack --stateless-rpc negotiation with correct per-round termination, streamed forwarding, and accumulated “haves/wants”.
    • Prevented hangs and tightened edge-case handling (EOF/malformed streams), including more accurate fetch completion tracking and error surfacing.
    • Ensured fetch request signing behavior remains correct for public vs private fetches across rounds.
  • Tests
    • Expanded real end-to-end git fetch coverage (multi-round boundaries, withheld-first behavior, negotiation denial, signing verification, and protocol edge cases).
    • Strengthened bounded subprocess timeout/cleanup coverage to avoid stdout/stderr deadlocks.
  • Chores
    • Updated Unix-focused dev dependencies to support the enhanced integration harness.

t added 4 commits July 11, 2026 19:40
… loop (#117)

git-remote-gitlawb advertised `connect`, so git spoke the stateful native
protocol, but handle_connect collapsed the exchange into one GET plus one POST.
A multi-round fetch (more than ~32 overlapping commits) deadlocked: git sent a
flush-terminated have-batch and blocked for an ACK/NAK the helper never sent,
while the helper blocked reading for a `done` git never sent.

Turn Phase 2 into a per-round stateless-RPC client loop. read_upload_pack_round
returns one round at a time (at a flush, done, or EOF) instead of buffering past
flushes waiting for `done`; negotiate_upload_pack captures the wants once and
POSTs a self-contained request per flush-terminated batch: wants, one flush,
every have accumulated so far, and exactly one terminator. Each ACK/NAK is
streamed back to git so it advances, until the done round returns the pack. The
node's `git upload-pack --stateless-rpc` keeps no state between POSTs, so wants
and all prior haves are re-sent every round and no intermediate flush survives
into a body (which would truncate the negotiation server-side).

Signing is preserved per round: every POST carries the Phase-1 decision (signed
after the 404 escalation for a private repo, anonymous for a public one), and a
mid-negotiation denial surfaces through the sanitized error path rather than
reading as an empty or successful fetch. The receive-pack path is unchanged.

Covers the deadlock repro, multi-round accumulation on the wire, per-round
signing for private and public fetches, mid-negotiation denial surfacing, the
withheld-shaped forwarding path, and single-round non-regression.
… loop (#117)

A committed integration test drives a real `git fetch` through the built helper
against a real `git upload-pack --stateless-rpc`, so the stateful-to-stateless
ACK bridging is proven by execution rather than reasoned. The main.rs mock tests
cannot falsify it: a pre-scripted Cursor never reacts to the server's ACKs. An
in-test shim replicates the node's v0 serving; the fetch is forced to at least
two negotiation rounds (a fixture that resolved in one round fails the test) and
the resulting object graph is verified.

The second scenario drives the withheld-blob shape (a full pack on the first
POST, as upload_pack_excluding does) through real git and records the outcome:
real git rejects a pack where it expected an ACK continuation ("expected
ACK/NAK, got ..."), so a multi-round fetch of a withheld repo does not complete.
The helper forwards and terminates cleanly rather than hanging, so this is a
node-side concern, not a helper defect, left as a follow-up per the plan's
Withheld-Path Decision. The test guards the helper's non-hang behavior and
surfaces the break if the assumption ever changes.
A code-review pass found the negotiation loop would POST once per bare `0000`
flush that carried no haves, so a malformed `wants + N*0000 + EOF` stream
amplified into N signed POSTs. Real git never sends a content-free mid-negotiation
flush and the peer is upstream of the local git process, so this was not
reachable in practice, but it left the loop unbounded per input. Skip a round
that carries no new haves (an empty flush) and only POST rounds with have content
or a terminator, which also makes the loop provably bounded by git's finite have
set. The fresh-clone done-with-no-haves round still POSTs.
Adds executed coverage for the negotiation branches the first pass left
unrun: an invalid pkt-line length (rejected, not underflowed), a non-EOF
read error (propagated, not swallowed as end-of-stream), an immediately
empty upload-pack request (skips the POST), a nonempty have-batch
terminated by EOF (POSTed as a final done round), and an empty
receive-pack body (skips the POST). Each was mutation-checked to fail
without its guard, so the coverage is load-bearing rather than vacuous.
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The helper now supports multi-round git-upload-pack negotiation through stateless RPC, preserves per-round signing behavior, keeps git-receive-pack single-request handling, counts completed fetches once, and adds unit and real-Git integration coverage.

Changes

Upload-pack negotiation

Layer / File(s) Summary
Round parsing and stateless POST flow
crates/git-remote-gitlawb/src/main.rs
Upload-pack input is split at flush, done, and EOF boundaries, with accumulated haves and one stateless-RPC POST per round; receive-pack remains a single complete request.
Signing and negotiation validation
crates/git-remote-gitlawb/src/main.rs
Tests cover exact-body signing, round segmentation, public/private fetch behavior, denial handling, EOF behavior, and protocol edge cases.
Fetch completion metrics
crates/gitlawb-node/src/api/repos.rs, crates/gitlawb-node/src/metrics.rs
The node detects finalizing requests, handles filtered-pack completion, and records fetch metrics once per negotiation.
End-to-end real Git fetch validation
crates/git-remote-gitlawb/tests/real_git_fetch.rs, crates/git-remote-gitlawb/Cargo.toml
A smart-HTTP shim and bounded subprocess harness exercise multi-round fetches, withheld responses, cleanup, timeouts, FETCH_HEAD, and repository integrity.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Git
  participant Helper
  participant HTTPShim
  participant UploadPack
  Git->>Helper: Send upload-pack negotiation round
  Helper->>HTTPShim: POST round
  HTTPShim->>UploadPack: Forward stateless request
  UploadPack-->>HTTPShim: Return ACK, NAK, or pack response
  HTTPShim-->>Helper: Return HTTP response
  Helper-->>Git: Stream response
Loading

Possibly related issues

Possibly related PRs

  • Gitlawb/node#119 — Both modify upload-pack POST construction and per-round signing behavior.
  • Gitlawb/node#28 — Both modify the node’s upload-pack handling and filtered-pack serving path.
  • Gitlawb/node#165 — Both modify node upload-pack request handling.

Suggested labels: crate:node, kind:test, sev:high

Suggested reviewers: vasanthdev2004

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the multi-round fetch fix for issue #117.
Description check ✅ Passed The description covers the bug, fix, verification, and follow-up well, though it omits some template sections.
Linked Issues check ✅ Passed The changes implement per-round stateless-RPC negotiation to resolve the incremental fetch deadlock in #117.
Out of Scope Changes check ✅ Passed The extra tests, deps, and metrics updates support the fetch fix and are not clearly unrelated churn.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-117-multiround-fetch-deadlock

Comment @coderabbitai help to get the list of available commands.

@beardthelion beardthelion added crate:git-remote git-remote-gitlawb — the git remote helper kind:bug Defect fix — wrong or unsafe behavior labels Jul 12, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
crates/git-remote-gitlawb/src/main.rs (1)

301-336: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicates post_pack_round's POST/status-check/forward logic.

Lines 313-334 re-implement almost exactly what post_pack_round (lines 502-533) does: build the signed POST, send, check status via http_error_message, read the response body, and forward it to stdout. Since post_pack_round already takes (client, post_url, service, signing_key, body, stdout) matching this call site's variables, the receive-pack path can just call it directly instead of duplicating the block.

♻️ Suggested refactor
     let mut request_body = Vec::new();
     stdin
         .read_to_end(&mut request_body)
         .context("reading receive-pack request")?;
     tracing::debug!("pack request: {} bytes from git", request_body.len());

     if request_body.is_empty() {
         // e.g., already up-to-date — nothing to send
         tracing::debug!("empty request body — skipping POST");
         return Ok(());
     }

-    tracing::debug!("POST {post_url} ({} bytes)", request_body.len());
-    let req = build_pack_post_request(&client, &post_url, service, &request_body, signing_key);
-
-    // Attach the body after signing so the pack bytes are moved, not cloned —
-    // packs can be large and the clone doubled peak memory on push.
-    let pack_resp = req
-        .body(request_body)
-        .send()
-        .with_context(|| format!("POST {post_url}"))?;
-
-    if !pack_resp.status().is_success() {
-        let status = pack_resp.status();
-        let body = read_error_body(pack_resp);
-        let path = format!("/{service}");
-        bail!("{}", http_error_message("POST", &path, status, &body, None));
-    }
-
-    let pack_bytes = pack_resp.bytes().context("reading pack response")?;
-    tracing::debug!("pack response: {} bytes from node", pack_bytes.len());
-
-    stdout.write_all(&pack_bytes)?;
-    stdout.flush()?;
-
-    Ok(())
+    post_pack_round(&client, &post_url, service, signing_key, request_body, &mut stdout)
 }

This keeps behavior identical (same log lines, same error path) while removing a divergence risk between the two copies.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/git-remote-gitlawb/src/main.rs` around lines 301 - 336, Replace the
duplicated POST, status-check, response-reading, and stdout-forwarding block
after the empty-body guard with a direct call to post_pack_round, passing
client, post_url, service, signing_key, request_body, and stdout. Preserve the
existing request-body logging and empty-body early return while reusing
post_pack_round for the shared behavior.
crates/git-remote-gitlawb/tests/real_git_fetch.rs (1)

354-436: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Temp repos leak on assertion failure.

cleanup(&[server, clone]) (lines 387, 429) only runs if every prior assert!/assert_eq! in the test succeeds. A failed assertion (e.g. completed, fsck, or the FETCH_HEAD equality checks) panics before cleanup, leaking the unique_dir()-created server/clone repos (each with a real object DB) under the OS temp dir. Shim already avoids this class of leak for its own thread/listener via Drop (lines 111-126); the repo directories don't get the same RAII treatment.

♻️ Wrap temp dirs in an RAII guard so they're removed even on panic
struct TempDir(PathBuf);

impl std::ops::Deref for TempDir {
    type Target = Path;
    fn deref(&self) -> &Path {
        &self.0
    }
}

impl Drop for TempDir {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.0);
    }
}

Then have unique_dir/build_divergent_repos return TempDir and drop the explicit cleanup(...) calls at the end of each test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/git-remote-gitlawb/tests/real_git_fetch.rs` around lines 354 - 436,
Introduce an RAII temporary-directory guard for paths created by unique_dir and
returned through build_divergent_repos, implementing path access and Drop to
remove each directory. Update both real_git_multi_round_fetch_completes and
real_git_withheld_shaped_first_post to use the guarded values and remove the
explicit cleanup calls, ensuring directories are deleted during assertion panics
as well as normal completion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/git-remote-gitlawb/tests/real_git_fetch.rs`:
- Around line 244-284: Update fetch_with_helper so the child’s stdout and stderr
are drained concurrently while the parent polls for completion, preventing
pipe-buffer backpressure from blocking git fetch. Preserve the existing
30-second timeout, kill-and-collect behavior, and boolean result semantics.

---

Nitpick comments:
In `@crates/git-remote-gitlawb/src/main.rs`:
- Around line 301-336: Replace the duplicated POST, status-check,
response-reading, and stdout-forwarding block after the empty-body guard with a
direct call to post_pack_round, passing client, post_url, service, signing_key,
request_body, and stdout. Preserve the existing request-body logging and
empty-body early return while reusing post_pack_round for the shared behavior.

In `@crates/git-remote-gitlawb/tests/real_git_fetch.rs`:
- Around line 354-436: Introduce an RAII temporary-directory guard for paths
created by unique_dir and returned through build_divergent_repos, implementing
path access and Drop to remove each directory. Update both
real_git_multi_round_fetch_completes and real_git_withheld_shaped_first_post to
use the guarded values and remove the explicit cleanup calls, ensuring
directories are deleted during assertion panics as well as normal completion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 38d3b6d3-d10b-460c-b569-9017a993168a

📥 Commits

Reviewing files that changed from the base of the PR and between ad7c2b2 and 00e3f83.

📒 Files selected for processing (2)
  • crates/git-remote-gitlawb/src/main.rs
  • crates/git-remote-gitlawb/tests/real_git_fetch.rs

Comment thread crates/git-remote-gitlawb/tests/real_git_fetch.rs

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P2] Do not convert an aborted negotiation into a done request
    crates/git-remote-gitlawb/src/main.rs:610
    A nonempty EOF is explicitly documented here as the case where Git aborted mid-negotiation, but it is combined with RoundEnd::Done, receives a synthetic 0009done\n, and is sent to the node. Cancelling a fetch after writing a partial have batch therefore still issues an authorized (and, for a private fetch, signed) upload-pack request, making the node generate and stream a pack to a client that has gone away. EOF should terminate the helper without a POST; only the actual done pkt should finalize the stateless request. The new nonempty_eof_batch_posts_as_final_round test currently locks in the incorrect behavior.

  • [P2] Drain the real Git child output while waiting for it
    crates/git-remote-gitlawb/tests/real_git_fetch.rs:266
    fetch_with_helper pipes both stdout and stderr, then only polls try_wait until the process exits. A fetch that emits more progress or error output than an OS pipe can hold blocks in Git before it exits, so this harness reaches its 30-second timeout and reports the protocol deadlock even when the helper is making progress. Drain both streams concurrently while enforcing the deadline (or do not pipe them) so the new load-bearing integration test can distinguish a real negotiation hang from pipe backpressure.

  • [P3] Make the integration-test repositories cleanup-safe
    crates/git-remote-gitlawb/tests/real_git_fetch.rs:356
    The server and clone directories are deleted only after every assertion succeeds. Any timeout, failed fetch, fsck failure, or panic exits before cleanup, leaving real Git repositories in the shared temporary directory; the predictable pid/counter names can also poison a later run if a pid is reused. Return RAII-owned temporary directories (or use tempfile) so cleanup runs while unwinding as well.

…git test harness (#192)

F1: a nonempty have-batch terminated by EOF means git aborted mid-negotiation, but
the fetch loop folded RoundEnd::Eof into the Done arm, appended a synthetic
0009done, and POSTed it — so a cancelled private fetch still issued an authorized,
signed upload-pack request and made the node generate and stream a full pack to a
client that had gone away. Split the arm: only a real done pkt finalizes; a
nonempty EOF terminates without a POST. The nonempty_eof_batch_posts_as_final_round
test that locked in the wrong behavior is flipped to assert no POST.

F2: fetch_with_helper piped stdout+stderr but only polled try_wait, so a fetch
emitting more than an OS pipe buffer (~64 KiB) blocked git before it exited and the
harness reported a false 30s deadlock. Drain both streams on reader threads while
enforcing the deadline, so the deadline measures a real protocol hang only.

F3: the integration repos were removed only on the success path (predictable
pid/counter names), so a timeout/panic leaked real git repos and could poison a
later run. Root them in a RAII tempfile::TempDir that drops on unwind.

RED->GREEN: nonempty_eof_batch_aborts_without_posting (pre-fix POSTs a synthetic
done → the aborted-negotiation POST fails against the single-response server; fixed
makes one request, no POST). repos_are_cleaned_up_on_unwind (temp dir gone after a
panic unwinds past the guard). Full git-remote-gitlawb suite 42 unit + 3 integration
green, fmt + clippy clean.
@beardthelion

Copy link
Copy Markdown
Collaborator Author

All three addressed on e3c322c.

Aborted EOF no longer POSTs (F1). The fetch loop folded RoundEnd::Eof into the Done arm, so a nonempty have-batch ending in EOF (git aborted mid-negotiation) got a synthetic 0009done and was POSTed, making the node generate and stream a pack to a client that had gone away. Split the arm: only a real done pkt finalizes the stateless request; a nonempty EOF terminates without a POST (the accumulated haves are discarded, there is no one to serve). The nonempty_eof_batch_posts_as_final_round test that locked in the old behavior is flipped to assert no POST.

Concurrent drain (F2). Factored the spawn/deadline logic into run_bounded, which now drains stdout and stderr on reader threads while enforcing the deadline, so a fetch emitting more than an OS pipe buffer can no longer block git before it exits and trip the timeout as a false deadlock. Covered by a load-bearing test: a child writing ~140 KiB to both streams completes under the drained runner and deadlocks (deadline trips) under the old try_wait-only one.

RAII cleanup (F3). The server and clone now live under a single tempfile::TempDir that drops on unwind, so a timeout, failed fetch, fsck failure, or panic cleans up while unwinding instead of leaking real repos; the randomized root also removes the predictable pid/counter naming. A catch_unwind test asserts the tree is gone after a mid-test panic.

Each fix is RED->GREEN with the guard reverted to confirm it is load-bearing.

@beardthelion
beardthelion requested a review from jatmn July 14, 2026 00:19

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/git-remote-gitlawb/tests/real_git_fetch.rs`:
- Around line 272-325: Update run_bounded so a timeout terminates the spawned
git fetch process and all descendant remote-helper processes, using
process-group termination or an equivalent descendant-reaping mechanism before
joining out_reader and err_reader. Preserve the existing completed result and
output collection behavior while ensuring inherited pipe handles are closed and
reader threads cannot hang.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 58807c87-026a-40d6-9bcf-6e2e1dfc5edd

📥 Commits

Reviewing files that changed from the base of the PR and between 00e3f83 and e3c322c.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (3)
  • crates/git-remote-gitlawb/Cargo.toml
  • crates/git-remote-gitlawb/src/main.rs
  • crates/git-remote-gitlawb/tests/real_git_fetch.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/git-remote-gitlawb/src/main.rs

Comment thread crates/git-remote-gitlawb/tests/real_git_fetch.rs

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P2] Kill the entire fetch process tree before joining the drainers
    crates/git-remote-gitlawb/tests/real_git_fetch.rs:305
    On the timeout path this kills only the top-level git fetch process and then joins the read_to_end threads. Its git-remote-gitlawb descendant inherits those stdout/stderr pipe FDs, so a helper stuck in its HTTP request keeps the pipes open after Git dies; the joins then wait for that helper's 300-second request timeout instead of returning the promised 30-second deadlock result. Start the fetch in a process group and terminate/reap that group (or otherwise reap descendants) before joining the readers. This is also the unresolved current-head CodeRabbit request.

  • [P2] Make the withheld-path integration test prove it reached the withheld response
    crates/git-remote-gitlawb/tests/real_git_fetch.rs:518
    The non-success branch accepts every prompt git fetch failure and only prints the observed POST count. A regression that fails before the helper sends the first request to the shim—for example a broken advertisement, helper lookup, or connection—therefore passes as the expected rejected filtered pack, so this new test no longer verifies the path it claims to record. Require at least one POST (and preferably assert the expected protocol failure when a failure remains acceptable) before accepting the nonzero exit.

…hheld guard load-bearing (#192)

Address jatmn's two P2 review findings on real_git_fetch.rs.

F1 (INV-22): run_bounded killed only the top-level `git fetch` on the
timeout path, so the git-remote-gitlawb helper descendant kept the
inherited stdout/stderr pipe write-ends open and the reader joins blocked
on the helper's ~300s HTTP timeout instead of the 30s deadline. Spawn the
child with process_group(0) and, on timeout, tear the whole group down
(SIGTERM, ESRCH poll, SIGKILL escalation, bounded cap, then reap) before
joining, mirroring gitlawb-node/src/git/smart_http.rs. libc is added as a
cfg(unix) dev-dep; non-unix keeps the child.kill() fallback so the
always-on test still compiles. New regression run_bounded_reaps_descendants_holding_the_pipe:
a backgrounded sleep inherits the pipe and outlives a 1s-deadline leader;
reverting to a leader-only kill turns it RED (elapsed ~10s).

F2 (INV-21): the non-success branch of real_git_withheld_shaped_first_post
accepted any `git fetch` failure and only printed the POST count, so a
failure before the first POST (broken advertisement, helper lookup,
connection) passed as the expected withheld rejection. Assert posts >= 1
before accepting the nonzero exit. Forcing a pre-POST failure (0 POSTs)
turns the assertion RED.
@beardthelion

Copy link
Copy Markdown
Collaborator Author

@jatmn both P2s addressed in 4a3f59c, each verified RED/GREEN by execution.

F1 (kill the whole process tree before joining): run_bounded now spawns with process_group(0) and, on the timeout branch, tears the whole group down (SIGTERM, ESRCH poll on kill(-pgid, 0), SIGKILL escalation after a grace, bounded cap, then reap) before joining the readers. That closes the git-remote-gitlawb descendant's inherited pipe write-ends too, so the joins return at the 30s deadline instead of the helper's ~300s HTTP timeout. Mirrors gitlawb-node/src/git/smart_http.rs; libc added as a cfg(unix) dev-dep, non-unix keeps the child.kill() fallback. New regression run_bounded_reaps_descendants_holding_the_pipe: reverting to the leader-only kill makes it RED (readers block ~10s on a backgrounded descendant holding the pipe), green with the group teardown.

F2 (prove the withheld test reached the withheld response): the non-success branch now asserts posts >= 1 before treating a nonzero exit as the withheld rejection, so a failure before the first POST (broken advertisement, helper lookup, connection) can no longer masquerade as the expected rejection. Forcing a pre-POST failure (fetch against a dead URL, 0 POSTs) makes the new assert fire; the real shim run stays green.

Full crate green (42 unit + 5 integration), fmt and workspace clippy clean.

@beardthelion
beardthelion requested a review from jatmn July 15, 2026 13:42

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P2] Keep the large-output regression test portable to the shipped Windows target
    crates/git-remote-gitlawb/tests/real_git_fetch.rs:558
    This test unconditionally spawns sh -c 'seq …', but the release workflow ships git-remote-gitlawb for x86_64-pc-windows-msvc and neither sh nor seq is a guaranteed Windows dependency. Consequently cargo test -p git-remote-gitlawb fails at spawn child on a normal Windows checkout before it tests the drainer. Gate the shell-based fixture to Unix or replace it with a platform-native/Rust output generator.

  • [P2] Terminate the remote-helper tree in the non-Unix timeout path
    crates/git-remote-gitlawb/tests/real_git_fetch.rs:379
    The Unix path creates and kills a process group because the git-remote-gitlawb descendant inherits Git's stdout/stderr pipe handles; the non-Unix fallback kills only the top-level git process and then joins read_to_end drainers. When a timed-out fetch leaves the helper blocked in its 300-second HTTP request, those inherited handles remain open and the joins wait for that timeout rather than returning after the promised 30 seconds. Use a Windows process-tree mechanism (for example a Job Object or equivalent) before joining the drainers, and cover that path on its supported platform.

…d windows target (#192)

The large-output and descendants tests re-invoke the test binary as their
fixtures instead of spawning sh/seq, so they run on x86_64-pc-windows-msvc;
the non-unix timeout path tears down the whole fetch tree with taskkill /T
before joining the drainers instead of killing only the leader. Also folds
the receive-pack POST block into post_pack_round (review nitpick), removing
the duplicated status-check/forward logic.
@beardthelion

Copy link
Copy Markdown
Collaborator Author

@jatmn both P2s addressed in 34040be, each verified by execution.

F1 (portable large-output fixture): the test binary now re-invokes itself as the output generator (an #[ignore]d fixture gated on GL_TEST_FIXTURE=emit writing ~136 KiB to both streams), so run_bounded_drains_large_output_without_deadlock keeps running on windows rather than being cfg-gated away; no sh/seq remains in the file. The guard stays load-bearing: reverting run_bounded to poll-then-read turns it RED (completed=false, 11.03s), GREEN with the drain restored.

F2 (non-unix tree teardown): the timeout branch now calls reap_tree, which runs taskkill /T /F /PID <leader> (your "or equivalent": a tree kill shipped in System32, no Job Object dev-dependency or unsafe in test code) before the drainer joins, then reaps the leader, mirroring reap_group. The descendants test dropped its #[cfg(unix)] and drives a self-exec leader plus pipe-holding grandchild, so the taskkill path executes on the platform that compiles it. A leader-only-kill mutation turns it RED (elapsed=10.003s against the <4s assert), GREEN with the teardown restored.

One honest limit: CI runs tests on ubuntu only, so the windows branch is compile-verified (the test file type-checks verbatim against x86_64-pc-windows-msvc), not executed. If you want a windows test job added to pr-checks, happy to do that as a follow-up.

Also folded the receive-pack POST block into post_pack_round (CodeRabbit's round-1 nitpick); behavior identical, receive-pack unit tests green.

@beardthelion
beardthelion requested a review from jatmn July 16, 2026 11:52

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P2] Count a multi-round negotiation as one fetch completion
    crates/git-remote-gitlawb/src/main.rs:604
    Each flush-terminated have batch now produces a successful git-upload-pack POST, but the server increments gitlawb_fetches_total after every successful POST even though that metric is documented as completed fetches. An incremental fetch with N negotiation rounds is therefore reported as N completed fetches (and the repeatedly accumulated request bodies are observed as separate pack sizes). Update the protocol/metrics boundary so intermediate stateless-RPC rounds are not counted as completed fetches, or redefine and separately expose request-level metrics.

  • [P2] Keep the timeout in force after the fetch leader exits
    crates/git-remote-gitlawb/tests/real_git_fetch.rs:393
    try_wait() marks the run complete as soon as the top-level git process exits, then the code joins the stdout/stderr drainers without any deadline. A remote-helper descendant can still hold those inherited pipe write ends after its parent exits, making either join wait for the helper's 300-second HTTP timeout (or indefinitely) despite the requested bound. The existing process-group cleanup only runs when the leader is still alive at the deadline, so add coverage for a leader-exits-first descendant and keep/reap the process group until the pipe readers have completed within the deadline.

t added 2 commits July 17, 2026 23:13
…RPC POST

git_upload_pack recorded a fetch and observed a pack size on every successful
POST. In HTTP stateless-RPC the plain serve path streams a pack only on the
finalizing `done` round, so an N-round incremental fetch was reported as N
completed fetches. The filtered path (upload_pack_excluding) instead serves a
self-contained full pack on the single POST that reaches it, with no `done`, so
gating purely on `done` would drop those private (path-scoped) fetches to zero.

Count once on the POST that actually completes a fetch: the `done` round on the
plain path (request_finalizes_fetch parses pkt-lines and fails closed on a
malformed body), or the one filtered-serve POST (served_filtered_pack).
should_count_fetch captures the decision; a completed fetch is exactly one such
POST, so a multi-round negotiation is never double-counted.

The observe_pack_size call still measures the request body, not the served pack;
that mislabel predates this change and is left as a follow-up.

Verified by execution: a three-round plain fetch and a no-`done` filtered serve
each record exactly one completion; reverting the gate to count per POST makes
the plain fetch record 3, and breaking the decision to AND drops both to 0.
…before a pipe-holder

run_bounded only tore down the process group on the deadline path. When the git
leader exits on its own but a descendant (the git-remote-gitlawb helper mid-HTTP
request) outlives it holding the inherited stdout/stderr write-ends, the reader
joins blocked on the helper's ~300s timeout, past the deadline run_bounded
promises. Close the group on the clean-exit path too, guarded by a kill(-pgid, 0)
liveness probe so a recycled pid is never signalled (the process-group ID stays
reserved while any member is alive). Adds a leader-exits-first fixture and a
regression test.

Verified by execution: with the clean-path reap removed the new test blocks ~10s
on the descendant (RED); with the reap it returns promptly.
@beardthelion

Copy link
Copy Markdown
Collaborator Author

Both addressed on e5baece (F1) and 6e78eb2 (F2), each verified by execution.

[P2] Count a multi-round negotiation as one fetch completion. git_upload_pack now counts a completed fetch (and observes the pack) only on the POST that completes one: the finalizing done round on the plain path, or the single POST that reaches the filtered path (upload_pack_excluding serves a self-contained pack with no done, so gating purely on done would have dropped path-scoped fetches to zero). A completed fetch is exactly one such POST. Verified: a three-round plain fetch and a no-done filtered serve each record one completion; counting per POST records 3 for the plain fetch. (observe_pack_size still measures the request body, not the served pack; that mislabel predates this PR and I've left it as a separate follow-up.)

[P2] Keep the timeout in force after the fetch leader exits. run_bounded now closes the process group on the clean-exit path too, not only at the deadline. When the git leader exits but a descendant still holds the inherited pipe write-ends, the reader joins would otherwise block on the helper's ~300s timeout; the group is signalled behind a kill(-pgid, 0) liveness probe so a reaped-and-recycled pid is never hit. Added a leader-exits-first fixture and regression: with the clean-path reap removed it blocks ~10s on the descendant, with it the joins return promptly. Unix-only, matching the existing teardown.

@beardthelion
beardthelion requested a review from jatmn July 18, 2026 04:26

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/gitlawb-node/src/api/repos.rs`:
- Around line 737-761: Update request_finalizes_fetch to recognize only 0000,
0001, and 0002 as zero-payload control packets; reject other lengths below 4 as
malformed. Track a saw_done flag instead of returning immediately when the
payload is done, continue validating every packet and trailing bytes, and return
saw_done only after the entire body has been parsed successfully.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2ddc9d14-29e2-4d11-9ff1-977d95633f20

📥 Commits

Reviewing files that changed from the base of the PR and between 34040be and 6e78eb2.

📒 Files selected for processing (3)
  • crates/git-remote-gitlawb/tests/real_git_fetch.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/metrics.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/git-remote-gitlawb/tests/real_git_fetch.rs

Comment thread crates/gitlawb-node/src/api/repos.rs

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P2] Count successful no-done fetches
    crates/gitlawb-node/src/api/repos.rs:709
    The node advertises multi_ack_detailed no-done, so a normal smart-HTTP client may finish a flush-terminated have round by receiving ACK <oid> ready followed immediately by the pack, without ever sending a done pkt-line. I reproduced that response with this checkout's exact git upload-pack --stateless-rpc path. In that successful case both finalizes_fetch and served_filtered_pack are false, so this gate records neither gitlawb_fetches_total nor the pack observation. Please derive completion from the response/protocol outcome rather than assuming every plain fetch ends with done, and add a no-done regression.

  • [P2] Do not count rejected filtered responses as completed fetches
    crates/gitlawb-node/src/api/repos.rs:686
    Setting served_filtered_pack for every upload_pack_excluding response labels the request as a completed fetch even when the client rejects the response. The committed real-Git scenario currently sends two POSTs and exits nonzero with expected ACK/NAK; against this handler both filtered POSTs would set this flag and increment the completion counter, reporting one failed fetch as two successful completions. Until #191 makes this negotiation valid, gate the metric on a genuinely final request/outcome (fresh filtered clones carry done) or use a request-level metric instead.

  • [P2] Keep the clean-exit timeout bounded on Windows
    crates/git-remote-gitlawb/tests/real_git_fetch.rs:431
    The new leader-exits-first cleanup runs only under cfg(unix). On Windows, if the top-level Git process exits while its remote-helper descendant remains blocked in the 300-second HTTP request, completed becomes true and execution goes straight to the reader-thread joins while that descendant still owns the pipe handles. The 30-second runner can therefore block for minutes; the matching regression is also Unix-only, and taskkill /T cannot recover the tree after the leader has exited. Use a Windows Job Object or another ownership mechanism established at spawn time, and exercise this ordering on Windows.

  • [P2] Avoid racing the process-wide metrics initializer in tests
    crates/gitlawb-node/src/api/repos.rs:1937
    This adds a second test that calls metrics::init, while the existing metrics test does the same and init uses an unsynchronized REGISTRY.get() check followed by several OnceLock::set(...).expect(...) calls. If both tests observe an empty registry, the loser panics at set INFO once (or a later lock); selecting these two tests with two test threads reproduced that panic immediately. Use an atomic one-time initializer such as get_or_init/Once, or make this test assert the pure decision without invoking the global initializer.

  • [P2] Reject malformed pkt-line headers instead of treating them as flushes
    crates/git-remote-gitlawb/src/main.rs:450
    Both UTF-8 and hex parse failures are converted to zero, so a header such as zzzz is handled exactly like the valid 0000 flush. An invalid opener followed by EOF now returns success without a POST, and an invalid terminator after valid have lines can make those lines go out as a synthesized, potentially signed flush round. This masks/desynchronizes corrupted protocol input; propagate either parse error and add non-hex/non-UTF-8 cases alongside the existing 0001 test.

  • [P2] Bind the withheld-path test to the expected rejection
    crates/git-remote-gitlawb/tests/real_git_fetch.rs:736
    Once one POST has reached the shim, this branch accepts every nonzero Git exit as the intended withheld-pack rejection. A truncated/corrupt response, later HTTP failure, or helper protocol regression after the first POST therefore leaves the test green even though the comments claim it proved the helper forwarded and terminated correctly. The current runtime error is specifically expected ACK/NAK; assert that failure signature (and the expected request/response shape) so unrelated post-POST failures cannot masquerade as the recorded #191 limitation.

t added 2 commits July 19, 2026 12:19
…flushing

A non-UTF-8 or non-hex 4-byte length prefix was collapsed to 0000 via
unwrap_or, so a garbled header was handled exactly like a flush -- masking
corrupted/desynchronized protocol input and letting accumulated have lines
go out as a synthesized flush round. Propagate the parse error instead;
a real 0000 still parses as a valid flush. (#192)
init() did an unsynchronized REGISTRY.get() check then OnceLock::set(...).
expect(...) for each metric, so two concurrent callers both passed the check
and the loser panicked at 'set INFO once'. Two tests call init(), so the
parallel test runner could hit this. Guard the body with std::sync::Once so it
runs exactly once. Verified: a 32-thread barrier stress-test panics before the
fix and passes after (run in isolation; not committed since the process-global
registry makes the race non-deterministic in the shared-binary suite). (#192)
t added 2 commits July 19, 2026 13:14
…request

The gitlawb_fetches_total gate keyed on whether the request carried `done`,
which both undercounts a no-done completion (a client can finish a flush-
terminated have round with `ACK <oid> ready` + pack and no `done` -> counted 0)
and, on the filtered path, counts a rejected response per POST (the pre-#191
two-POST negotiation the client refuses was counted twice).

Derive completion from the response instead. upload_pack / upload_pack_excluding
now return whether they served a pack; response_served_pack detects the PACK
magic in both side-band-64k and raw framing. The plain path counts when the
response delivered a pack (catches no-done, skips negotiation-only rounds so an
N-round fetch counts once); the filtered path stays gated on the finalizing
`done` round until #191 makes that negotiation valid. Also tighten
request_finalizes_fetch to reject 0003 framing and trailing bytes after the
`done` pkt-line. (#192 F1/F2 + CodeRabbit repos.rs:761)
… rejection

The test accepted any nonzero git exit (with posts>=1) as the recorded #191
withheld rejection, so a truncated response or a later HTTP failure after the
first POST could pass green. Assert the specific `expected ACK/NAK` signature so
only that break passes, and pin LC_ALL=C so the diagnostic stays the untranslated
English string on a git build with l10n installed. (#192 F6)
@beardthelion

Copy link
Copy Markdown
Collaborator Author

Pushed 3ceba33. Each fix has an adversarial regression that fails on the old behavior and passes on the new; the full workspace suite and clippy are green.

  • Count no-done fetches / don't count rejected filtered (repos.rs): the completion metric keys off the response outcome now, not the request. upload_pack / upload_pack_excluding report whether they served a pack (response_served_pack finds the PACK magic in both side-band-64k and raw framing). The plain path counts when a pack was served, so a no-done ACK <oid> ready + pack counts once and a negotiation-only round counts zero; the filtered path stays gated on the finalizing done round, so the pre-upload_pack_excluding breaks incremental (multi-round) fetch of withheld repos: pack sent mid-negotiation, git aborts with 'expected ACK/NAK' #191 two-POST rejection no longer double-counts. no_done_plain_fetch_counts_once and rejected_filtered_fetch_counts_zero_not_two are red on the old finalizes || served_filtered rule.
  • Reject malformed pkt-line headers (main.rs): a non-UTF-8 or non-hex length prefix errors now instead of collapsing to a 0000 flush; a real 0000 still parses as a flush. Added non-hex and non-UTF-8 cases.
  • Metrics init race: metrics::init is guarded by std::sync::Once, so concurrent callers can't both pass the old REGISTRY.get() check and race the OnceLock setters. A 32-thread barrier stress-test panics before the fix and passes after.
  • Bind the withheld-path test: it asserts the specific expected ACK/NAK signature now (with LC_ALL=C pinned so a localized git build doesn't translate it away), so a truncated response or a later failure after the first POST no longer passes as the withheld rejection.
  • request_finalizes_fetch framing: rejects 0003 framing and trailing bytes after done; 00030009done\n and 0009done\nx return false.

On the Windows clean-exit timeout: I'm leaving the Windows ownership mechanism for a follow-up rather than shipping it here. CI is Linux-only (every pr-checks job is ubuntu-latest) and the matching regression is cfg(unix), so a Windows Job Object implementation would go in untested, which I'd rather not do in this PR.

Re-requesting your review.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P2] Keep the clean-exit harness bounded on Windows
    crates/git-remote-gitlawb/tests/real_git_fetch.rs:435
    The clean-exit cleanup that prevents a remote-helper descendant from retaining the reader pipes is compiled only on Unix. On Windows, try_wait can observe the git fetch leader exiting while git-remote-gitlawb remains blocked in its 300-second HTTP request with inherited stdout/stderr handles; the following reader joins then wait for that descendant, despite the runner's 30-second bound. taskkill /T is only usable while the leader remains alive, and the corresponding regression is Unix-only. Establish tree ownership at spawn time (for example, with a Job Object) and tear it down before joining, with a Windows regression.

…ws (#192)

run_bounded's clean-exit descendant reap was Unix-only: on Windows a
git fetch leader that exits cleanly leaves the git-remote-gitlawb helper
blocked in its ~300s HTTP request holding the inherited pipes, and
taskkill /T can't reach a descendant once the leader is gone, so the
reader joins blew past the promised bound. The child is now assigned to
a Job Object at spawn (the analog of the Unix process_group(0)), and the
job is terminated on both the timeout and clean-exit paths, which kills
the whole tree regardless of leader liveness. KILL_ON_JOB_CLOSE is the
safety net. The clean-exit regression is now cross-platform so it
exercises this on a future Windows CI lane (#228).

Unix tests unchanged and green; the Windows path is compile-verified for
x86_64-pc-windows-msvc (runtime coverage lands with #228's CI lane).
@beardthelion

Copy link
Copy Markdown
Collaborator Author

Implemented, reversing my earlier deferral on this. Pushed as 443908d.

Last round I punted the Windows clean-exit case as a follow-up on the grounds that CI is Linux-only. That was the wrong call, and you were right to keep pushing: git-remote-gitlawb is a shipped Windows binary (release.yml builds x86_64-pc-windows-msvc and ships gl + git-remote-gitlawb), so deferring it meant shipping the untested path, not avoiding it.

run_bounded now assigns the child to a Job Object at spawn (the analog of the Unix process_group(0)) and terminates the job on both the timeout and clean-exit paths, so the whole fetch tree dies regardless of leader liveness. That closes exactly the hole you described: the clean-exit reap was #[cfg(unix)]-only, and taskkill /T can't reach a descendant once the leader has exited, whereas terminating the job doesn't depend on the leader. KILL_ON_JOB_CLOSE is the safety net for a stray. The clean-exit regression is now cross-platform so it exercises this teardown.

On verification, honestly: the Unix path is unchanged and green (44 + 6 tests, clippy/fmt clean). The Windows path is compile-verified for x86_64-pc-windows-msvc — the full-crate cross-check is blocked by ring's C build not cross-compiling on the aarch64 dev host, so I type-checked the complete run_bounded + Job Object code in isolation against the msvc target instead. Its runtime behavior is unverified until it actually runs on Windows, which is the real gap underneath this: we ship the binary but never run its tests on Windows. I filed that as #228 (a non-blocking Windows CI lane), which is what makes this regression load-bearing rather than never-run.

@beardthelion
beardthelion requested a review from jatmn July 21, 2026 15:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

crate:git-remote git-remote-gitlawb — the git remote helper kind:bug Defect fix — wrong or unsafe behavior

Projects

None yet

Development

Successfully merging this pull request may close these issues.

git-remote-gitlawb deadlocks on incremental (multi-round) fetch

2 participants