Skip to content

Commit 5069cd1

Browse files
author
t
committed
fix(node,concurrency): resolve #174 code-review findings
Review of the served-git concurrency cap found no P0/P1; these are the verified P2 follow-ups. config: the max_concurrent_git_ops doc overclaimed that "every capped path is duration-bounded." The rev-list object enumeration in the withheld-blob path still runs in an uncancellable spawn_blocking, so a stuck rev-list can hold its slot until git exits. Scope the guarantee to the streaming stages and name the residual. Also tighten the fairness claim: the receive-pack advertisement shares the read pool (a shed advertisement is a cheap retryable GET); only the push POST is on the isolated write pool. api/repos: add info_refs_per_caller_cap_keys_on_did_not_ip, the missing handler proof that a signed caller is keyed by its DID, not its source IP. Filling the DID slot sheds a request from a free IP; collapsing read_caller_key to its IP arm turns the assertion green-not-503 (mutation-verified RED). api/repos: extract acquire_read_caller_permit so both read handlers share one shed path instead of a duplicated match block. rate_limit: recover from a poisoned PerCallerConcurrency mutex instead of panicking. The critical section is pure counter arithmetic and cannot poison the lock, but a panic there would brick the limiter for every caller. 505 tests pass; clippy -D warnings and fmt clean. Part of #174.
1 parent 6336657 commit 5069cd1

3 files changed

Lines changed: 122 additions & 34 deletions

File tree

‎crates/gitlawb-node/src/api/repos.rs‎

Lines changed: 103 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -575,18 +575,12 @@ pub async fn git_info_refs(
575575
peer,
576576
state.push_limiter_trust,
577577
);
578-
let _caller_permit = match &caller_key {
579-
Some(k) => match state.git_read_per_caller.try_acquire(k) {
580-
Some(p) => Some(p),
581-
None => {
582-
tracing::warn!(repo = %name, caller = %k, "per-caller read cap reached; shedding info/refs with 503");
583-
return Err(AppError::Overloaded(
584-
"git read service at capacity for this caller, retry shortly".into(),
585-
));
586-
}
587-
},
588-
None => None,
589-
};
578+
let _caller_permit = acquire_read_caller_permit(
579+
&state.git_read_per_caller,
580+
caller_key.as_deref(),
581+
name,
582+
"info/refs",
583+
)?;
590584

591585
// For receive-pack (push), download the latest from Tigris so the client
592586
// sees the same refs that acquire_write() will operate on.
@@ -656,6 +650,30 @@ fn read_caller_key(
656650
}
657651
}
658652

653+
/// Acquire the per-caller read sub-cap permit (#174), or shed with a 503. `key` is
654+
/// `None` when no caller key resolves — that request is bounded by the global read
655+
/// pool only and is never shed here (returns `Ok(None)`). `handler` labels the shed
656+
/// log line. Shared by both read handlers so the two acquire sites cannot drift.
657+
fn acquire_read_caller_permit(
658+
limiter: &crate::rate_limit::PerCallerConcurrency,
659+
key: Option<&str>,
660+
repo: &str,
661+
handler: &str,
662+
) -> Result<Option<crate::rate_limit::PerCallerPermit>> {
663+
match key {
664+
Some(k) => match limiter.try_acquire(k) {
665+
Some(p) => Ok(Some(p)),
666+
None => {
667+
tracing::warn!(repo = %repo, caller = %k, handler, "per-caller read cap reached; shedding with 503");
668+
Err(AppError::Overloaded(
669+
"git read service at capacity for this caller, retry shortly".into(),
670+
))
671+
}
672+
},
673+
None => Ok(None),
674+
}
675+
}
676+
659677
/// Map an error from a `smart_http` git service call to the right `AppError`:
660678
/// [`smart_http::GitServiceTimeout`] to 504, a malformed client request to 400,
661679
/// anything else to a 500 git error. Pure (no logging) so it is unit-testable;
@@ -712,18 +730,12 @@ pub async fn git_upload_pack(
712730
// visibility-denied caller never consumes a scarce read slot. Keyed per-DID
713731
// when signed, else per-source-IP; no resolvable key -> global read pool only.
714732
let caller_key = read_caller_key(caller, &headers, peer, state.push_limiter_trust);
715-
let _caller_permit = match &caller_key {
716-
Some(k) => match state.git_read_per_caller.try_acquire(k) {
717-
Some(p) => Some(p),
718-
None => {
719-
tracing::warn!(repo = %name, caller = %k, "per-caller read cap reached; shedding upload-pack with 503");
720-
return Err(AppError::Overloaded(
721-
"git read service at capacity for this caller, retry shortly".into(),
722-
));
723-
}
724-
},
725-
None => None,
726-
};
733+
let _caller_permit = acquire_read_caller_permit(
734+
&state.git_read_per_caller,
735+
caller_key.as_deref(),
736+
name,
737+
"upload-pack",
738+
)?;
727739

728740
let disk_path = state
729741
.repo_store
@@ -2798,6 +2810,73 @@ mod tests {
27982810
);
27992811
}
28002812

2813+
/// #174 SC2 (per-DID key): a SIGNED caller is keyed by its DID, not its source
2814+
/// IP. Fill the DID's single read slot, then drive a request carrying that
2815+
/// `AuthenticatedDid` from an IP whose own slot is free — it must shed 503,
2816+
/// proving the key is the DID. A second request from the SAME IP but a DIFFERENT
2817+
/// DID is not shed. Collapse `read_caller_key` to its IP arm and the first
2818+
/// assertion goes green-not-503 (the IP slot is free) — this is the per-DID half
2819+
/// of the keying proof that the per-IP probes above do not cover.
2820+
#[sqlx::test]
2821+
async fn info_refs_per_caller_cap_keys_on_did_not_ip(pool: sqlx::PgPool) {
2822+
use axum::body::Body;
2823+
use axum::extract::ConnectInfo;
2824+
use axum::http::{Method, Request, StatusCode};
2825+
use std::net::SocketAddr;
2826+
use tower::ServiceExt;
2827+
2828+
let mut state = crate::test_support::test_state(pool).await;
2829+
state.git_read_per_caller = crate::rate_limit::PerCallerConcurrency::new(1, 100);
2830+
state.push_limiter_trust = crate::rate_limit::TrustedProxy::None;
2831+
state
2832+
.db
2833+
.upsert_mirror_repo("z6pcdid", "pc", "/tmp/pc-nonexistent", None, false)
2834+
.await
2835+
.unwrap();
2836+
2837+
let did_a = "did:key:z6MkPerCallerKeyingProofDidAAAAAAAAAAAAAAAA";
2838+
let did_b = "did:key:z6MkPerCallerKeyingProofDidBBBBBBBBBBBBBBBB";
2839+
let peer: SocketAddr = "203.0.113.51:5000".parse().unwrap();
2840+
2841+
// Fill DID_A's single read slot; the IP's own slot is left untouched.
2842+
let _slot = state
2843+
.git_read_per_caller
2844+
.try_acquire(did_a)
2845+
.expect("first slot for this DID");
2846+
2847+
// Signed as DID_A, from `peer` (whose IP slot is free): keyed by DID -> shed.
2848+
let router = crate::server::build_router(state.clone());
2849+
let mut req = Request::builder()
2850+
.method(Method::GET)
2851+
.uri("/z6pcdid/pc/info/refs?service=git-upload-pack")
2852+
.body(Body::empty())
2853+
.unwrap();
2854+
req.extensions_mut().insert(ConnectInfo(peer));
2855+
req.extensions_mut()
2856+
.insert(crate::auth::AuthenticatedDid(did_a.to_string()));
2857+
assert_eq!(
2858+
router.oneshot(req).await.unwrap().status(),
2859+
StatusCode::SERVICE_UNAVAILABLE,
2860+
"a signed caller at its per-DID read cap must shed with 503 (keyed by DID, not the free IP slot)"
2861+
);
2862+
2863+
// Same IP, DIFFERENT DID -> its own budget -> not shed by DID_A's saturation.
2864+
let router2 = crate::server::build_router(state.clone());
2865+
let mut req2 = Request::builder()
2866+
.method(Method::GET)
2867+
.uri("/z6pcdid/pc/info/refs?service=git-upload-pack")
2868+
.body(Body::empty())
2869+
.unwrap();
2870+
req2.extensions_mut().insert(ConnectInfo(peer));
2871+
req2.extensions_mut()
2872+
.insert(crate::auth::AuthenticatedDid(did_b.to_string()));
2873+
assert_ne!(
2874+
router2.oneshot(req2).await.unwrap().status(),
2875+
StatusCode::SERVICE_UNAVAILABLE,
2876+
"a different DID from the same IP must not be shed by another DID's saturated budget"
2877+
);
2878+
}
2879+
28012880
/// #174 SC2 (None-key): a request with no resolvable caller key (no ConnectInfo,
28022881
/// no trusted header) must NOT be shed by the per-caller cap even when another
28032882
/// caller's budget is full — it is bounded by the global read pool only. A None

‎crates/gitlawb-node/src/config.rs‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -244,19 +244,23 @@ pub struct Config {
244244
/// pack-objects + threads). Size below the process budget with headroom.
245245
///
246246
/// This is the READ pool (`git_read_semaphore`): upload-pack and both info/refs
247-
/// advertisements. Authenticated pushes draw from a separate write pool
248-
/// (`max_concurrent_git_pushes`) and each caller is additionally bounded by
249-
/// `max_concurrent_reads_per_caller`, so an anonymous flood can neither shed a
250-
/// push nor monopolize reads (#174).
247+
/// advertisements. The authenticated push POST draws from a separate write pool
248+
/// (`max_concurrent_git_pushes`) that anonymous reads can never reach, and each
249+
/// caller is additionally bounded by `max_concurrent_reads_per_caller`, so an
250+
/// anonymous flood cannot shed the actual push nor monopolize reads (#174). (The
251+
/// receive-pack advertisement itself shares the read pool; a shed advertisement
252+
/// is a cheap retryable GET, and the write POST it precedes always has capacity.)
251253
///
252-
/// A permit is held for the whole op, and every capped path is now
254+
/// A permit is held for the whole op. Every git subprocess that STREAMS is
253255
/// duration-bounded and reaps its process group on disconnect: upload-pack,
254256
/// receive-pack, and both info/refs advertisements run under
255257
/// `git_service_timeout_secs` with `process_group(0)` teardown, and the
256258
/// withheld-blob (`upload_pack_excluding`) pack-objects stage runs on the async
257-
/// side under the same teardown. So a hung git can no longer pin a slot and a
258-
/// client disconnect no longer orphans its git child (#174 closed the two
259-
/// duration/cancellation gaps this comment previously tracked as follow-up).
259+
/// side under the same teardown (#174 closed the two duration/cancellation gaps
260+
/// this comment previously tracked). The one remaining blocking stage is the
261+
/// `rev-list` object enumeration in the withheld-blob path — a bounded walk that
262+
/// terminates, run off the async runtime; it is not process-group-reaped on
263+
/// disconnect, so a stuck rev-list can still hold its slot until git exits.
260264
///
261265
/// Default: 128. Must be between 1 and 1_048_576; the ceiling keeps the value
262266
/// well under tokio's `Semaphore` permit limit so an oversized value is a

‎crates/gitlawb-node/src/rate_limit.rs‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,12 @@ impl PerCallerConcurrency {
178178
/// `None` (shed) otherwise. Reject-before-insert: a new key at `max_keys` is
179179
/// rejected without allocating.
180180
pub fn try_acquire(&self, key: &str) -> Option<PerCallerPermit> {
181-
let mut map = self.state.lock().expect("per-caller mutex poisoned");
181+
// Recover from a poisoned lock rather than panicking: the critical section
182+
// is pure counter arithmetic and cannot itself panic, so a poisoned mutex
183+
// would only ever come from an unrelated abort, and a slightly-off count
184+
// self-heals as permits drop. A panic here would instead brick the limiter
185+
// for every caller (each subsequent lock re-panics).
186+
let mut map = self.state.lock().unwrap_or_else(|e| e.into_inner());
182187
match map.get_mut(key) {
183188
Some(count) => {
184189
if *count >= self.per_caller {
@@ -207,7 +212,7 @@ impl PerCallerConcurrency {
207212

208213
impl Drop for PerCallerPermit {
209214
fn drop(&mut self) {
210-
let mut map = self.state.lock().expect("per-caller mutex poisoned");
215+
let mut map = self.state.lock().unwrap_or_else(|e| e.into_inner());
211216
if let Some(count) = map.get_mut(&self.key) {
212217
*count -= 1;
213218
if *count == 0 {

0 commit comments

Comments
 (0)