Skip to content

Commit cd67718

Browse files
beardtheliont
andauthored
fix(node,git): bound a hung served git with a total-duration timeout (#62) (#165)
* feat(node,git): bound a hung served git with a total-duration timeout (#62) Part 1 of #62. A served git upload-pack/receive-pack that neither finishes nor disconnects parks forever, pinning a git PID (and, for receive-pack, holding the write lock); on a public repo an anonymous caller can trigger it. No tower TimeoutLayer exists and axum imposes no default. Wrap the child interaction in tokio::time::timeout, bounded by a new GITLAWB_GIT_SERVICE_TIMEOUT_SECS knob (default 600, must be positive). Drain stdout/stderr concurrently with the stdin write so a large body can't deadlock. On expiry run_git_service returns a typed GitServiceTimeout and reaps the WHOLE process group before returning (SIGTERM so git clears its .git/*.lock, wait for the leader and its grandchildren like index-pack to exit, escalate to SIGKILL past a grace, hard-capped), so a caller releasing the receive-pack write lock can't race a still-live git on the same repo. The handlers map GitServiceTimeout to a distinct 504 (not the generic 500 git error, and clear of the read-gate 404 / auth 401 the client keys retries on) via a pure, unit-testable classifier rather than matching the anyhow string. Surface git's own non-zero exit (its stderr) before a stdin-write EPIPE so a malformed body is classified 400, not 500. Out of scope, deferred in #62: the info/refs advertisement and the withheld-blob path (spawn_blocking, which tokio timeout cannot cancel); both remain unbounded (noted in the config knob's docs). Tests: the timeout fires and tears the group down; it reaps the whole group before returning (RED if the reap is leader-only); the wrap is load-bearing (RED via an outer bound, not a hang); the error must be the typed timeout; the 504 mapping and classifier are unit-tested; config rejects 0. Full suite green. * fix(review): harden #62 timeout tests and drop a dead reap flag - test(node): cover the `protocol error` arm of git_service_app_error independently (the input has no "bad line length" substring, so it isolates the second classifier arm and goes RED if that arm is removed) - test(node): make the reap-before-return test's grandchild IGNORE SIGTERM (`trap "" TERM`) and outlive the ~4s reap cap, so the SIGKILL escalation is load-bearing. Previously the grandchild self-exited under the cap, so neutering the SIGKILL still passed; the escalation was exercised but not required. Verified: RED with SIGKILL disabled, GREEN with it. - test(node): add a receive-pack timeout test. Every prior timeout test used an upload-pack fake; this proves the push path (which also holds the repo write lock) is bounded too. RED-verified with the internal timeout removed. - test(node): poll for the pidfile in the reap-before-return timeout test so a loaded runner can't false-panic before the fake git writes it - remove the redundant `sigkilled` guard in reap_group_on_timeout; step == 200 fires exactly once in the 0..400 loop, so no re-entry guard is needed and the SIGKILL still fires exactly once * fix(node): log malformed receive-pack as warn, not error (#165) git_receive_pack's error classifier routed every non-Timeout error, including client-caused BadRequest (400), through tracing::error!. Mirror the git_upload_pack arm so a malformed push logs at warn level instead of as a server error. * test(node,git): make smart_http timeout tests pass under the parallel runner (#165) Addresses jatmn's review of 41eca0b. [P2] The #62 timeout tests failed under `cargo test --workspace`: - run_git_service_tears_down_group_when_future_dropped re-polled a completed future ("async fn resumed after completion"): the advance-until-pids loop ignored the timeout's Ok(_) (future-finished) case and polled the resolved future again. It now breaks on early completion. - Under fork-storm load a freshly-written fake `git` transiently fails to exec (ETXTBSY) or is timed out before recording its pids. Added a fake_git_run_with_pids retry helper (used by the four pid-reading tests) plus a matching inline retry on the drop test, with growing backoff for the correlated bursts and a 12-attempt cap that still fails loudly on a genuine never-spawns regression. Reverted two tests from a speculative 2000ms bound back to 300ms/1000ms now that the retry covers the timeout-vs-pidfile race. [P3] Documented GITLAWB_GIT_SERVICE_TIMEOUT_SECS in .env.example and the README config table (default 600; bounds upload-pack/receive-pack, not info/refs or the withheld-blob path). The tests stay load-bearing: breaking process_group(0), the post-reap disarm, the timeout-arm reap, or the internal timeout each still turns the corresponding test red (mutation-verified). --------- Co-authored-by: t <t@t>
1 parent 6bafaa6 commit cd67718

6 files changed

Lines changed: 621 additions & 80 deletions

File tree

‎.env.example‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ GITLAWB_PUBLIC_READ=true
9090
# Maximum git smart-HTTP pack request size, in bytes.
9191
GITLAWB_MAX_PACK_BYTES=2147483648
9292

93+
# Max seconds a served git upload-pack / receive-pack (clone / push) may run
94+
# before it is aborted with a 504. Bounds a hung git that would otherwise pin a
95+
# worker and, on push, the repo write lock. Does NOT cover the info/refs
96+
# advertisement or the withheld-blob path, which remain unbounded. Default 600.
97+
GITLAWB_GIT_SERVICE_TIMEOUT_SECS=600
98+
9399
# ── Push rate limiting (git-receive-pack flood brake) ─────────────────────
94100
# Max receive-pack requests (info/refs advertisement + push POST) per client
95101
# IP per hour. 0 disables. Default 600.

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,7 @@ Important node settings:
343343
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. |
344344
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
345345
| `GITLAWB_MAX_PACK_BYTES` | Max git pack body size for smart-HTTP routes. |
346+
| `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` | Max seconds a served git upload-pack/receive-pack may run before it is aborted (504). Default 600. Does not bound `info/refs` or the withheld-blob path. |
346347
| `GITLAWB_TIGRIS_BUCKET` | Optional S3/Tigris shared repo storage bucket. |
347348
| `GITLAWB_PINATA_JWT` | Optional Pinata/IPFS warm-storage pinning. |
348349
| `GITLAWB_IRYS_URL` | Optional Irys/Arweave permanent anchoring. |

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

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,26 @@ pub async fn git_info_refs(
578578
})
579579
}
580580

581+
/// Map an error from a `smart_http` git service call to the right `AppError`:
582+
/// [`smart_http::GitServiceTimeout`] to 504, a malformed client request to 400,
583+
/// anything else to a 500 git error. Pure (no logging) so it is unit-testable;
584+
/// callers add their own tracing.
585+
fn git_service_app_error(err: &anyhow::Error) -> AppError {
586+
if err
587+
.downcast_ref::<smart_http::GitServiceTimeout>()
588+
.is_some()
589+
{
590+
AppError::Timeout("git service timed out".into())
591+
} else {
592+
let msg = err.to_string();
593+
if msg.contains("bad line length") || msg.contains("protocol error") {
594+
AppError::BadRequest(msg)
595+
} else {
596+
AppError::Git(msg)
597+
}
598+
}
599+
}
600+
581601
/// POST /:owner/:repo.git/git-upload-pack
582602
pub async fn git_upload_pack(
583603
State(state): State<AppState>,
@@ -615,8 +635,9 @@ pub async fn git_upload_pack(
615635
// No path-scoped rule can withhold an individual blob, and the whole-repo
616636
// "/" gate above already enforced repo-level access. Skip the per-blob
617637
// withheld walk and serve the pack directly.
638+
let git_timeout = std::time::Duration::from_secs(state.config.git_service_timeout_secs);
618639
let resp = if !visibility_pack::has_path_scoped_rule(&rules) {
619-
smart_http::upload_pack(&disk_path, body).await
640+
smart_http::upload_pack(&disk_path, body, git_timeout).await
620641
} else {
621642
// withheld_blob_oids walks every ref with blocking `git ls-tree`; keep
622643
// that off the async worker thread.
@@ -641,21 +662,22 @@ pub async fn git_upload_pack(
641662
};
642663

643664
if withheld.is_empty() {
644-
smart_http::upload_pack(&disk_path, body).await
665+
smart_http::upload_pack(&disk_path, body, git_timeout).await
645666
} else {
646667
tracing::info!(repo = %name, caller = ?caller, withheld = withheld.len(), "serving filtered pack");
647668
smart_http::upload_pack_excluding(&disk_path, body, &withheld).await
648669
}
649670
}
650671
.map_err(|e| {
651-
let msg = e.to_string();
652-
if msg.contains("bad line length") || msg.contains("protocol error") {
653-
tracing::warn!(repo = %name, err = %msg, "git-upload-pack: bad client request");
654-
AppError::BadRequest(msg)
655-
} else {
656-
tracing::error!(repo = %name, err = %msg, "git-upload-pack failed");
657-
AppError::Git(msg)
672+
let app = git_service_app_error(&e);
673+
match &app {
674+
AppError::Timeout(_) => tracing::warn!(repo = %name, "git-upload-pack timed out"),
675+
AppError::BadRequest(msg) => {
676+
tracing::warn!(repo = %name, err = %msg, "git-upload-pack: bad client request")
677+
}
678+
_ => tracing::error!(repo = %name, err = %e, "git-upload-pack failed"),
658679
}
680+
app
659681
})?;
660682
crate::metrics::record_fetch(&format!("{owner}/{name}"));
661683
crate::metrics::observe_pack_size(body_len as f64);
@@ -902,16 +924,24 @@ pub async fn git_receive_pack(
902924
let disk_path = guard.path().to_path_buf();
903925
tracing::debug!(repo = %name, path = %disk_path.display(), "running git receive-pack");
904926
let body_len = body.len();
905-
let receive_result = smart_http::receive_pack(&disk_path, body).await;
927+
let git_timeout = std::time::Duration::from_secs(state.config.git_service_timeout_secs);
928+
let receive_result = smart_http::receive_pack(&disk_path, body, git_timeout).await;
906929

907930
// Always release the advisory lock — even on error — to prevent stale locks
908931
// from blocking subsequent pushes. Only upload to Tigris when the push
909932
// succeeded; uploading a half-applied repo would propagate corruption.
910933
guard.release(receive_result.is_ok()).await;
911934

912935
let result = receive_result.map_err(|e| {
913-
tracing::error!(repo = %name, err = %e, "git receive-pack failed");
914-
AppError::Git(e.to_string())
936+
let app = git_service_app_error(&e);
937+
match &app {
938+
AppError::Timeout(_) => tracing::warn!(repo = %name, "git receive-pack timed out"),
939+
AppError::BadRequest(msg) => {
940+
tracing::warn!(repo = %name, err = %msg, "git receive-pack: bad client request")
941+
}
942+
_ => tracing::error!(repo = %name, err = %e, "git receive-pack failed"),
943+
}
944+
app
915945
})?;
916946

917947
// Update the repo's updated_at timestamp after a successful push
@@ -1795,6 +1825,32 @@ mod tests {
17951825
const OWNER_SHORT: &str = "z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH";
17961826
const STRANGER_DID: &str = "did:key:z6Mkffonly5tranger0000000000000000000000000000000";
17971827

1828+
#[test]
1829+
fn git_service_app_error_classifies_timeout_bad_request_and_git() {
1830+
// GitServiceTimeout carried through anyhow -> 504 Timeout.
1831+
let timeout_err: anyhow::Error = smart_http::GitServiceTimeout.into();
1832+
assert!(matches!(
1833+
git_service_app_error(&timeout_err),
1834+
AppError::Timeout(_)
1835+
));
1836+
// A malformed client request -> 400.
1837+
let bad = anyhow::anyhow!("fatal: bad line length character: 0000");
1838+
assert!(matches!(
1839+
git_service_app_error(&bad),
1840+
AppError::BadRequest(_)
1841+
));
1842+
// The `protocol error` marker (with no "bad line length" substring) also
1843+
// -> 400, exercising the second arm of the classifier independently.
1844+
let proto = anyhow::anyhow!("fatal: protocol error: unexpected flush packet");
1845+
assert!(matches!(
1846+
git_service_app_error(&proto),
1847+
AppError::BadRequest(_)
1848+
));
1849+
// Anything else -> 500 git error.
1850+
let other = anyhow::anyhow!("some other git failure");
1851+
assert!(matches!(git_service_app_error(&other), AppError::Git(_)));
1852+
}
1853+
17981854
fn repo_owned_by(owner_did: &str) -> crate::db::RepoRecord {
17991855
let now = chrono::Utc::now();
18001856
crate::db::RepoRecord {

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,22 @@ pub struct Config {
166166
/// in flight and exits. Default: 30s.
167167
#[arg(long, env = "GITLAWB_SHUTDOWN_GRACE_SECS", default_value_t = 30)]
168168
pub shutdown_grace_secs: u64,
169+
170+
/// Maximum wall-clock time a single served git operation (upload-pack /
171+
/// receive-pack through `run_git_service`) may run before it is aborted and
172+
/// its process group torn down, in seconds. Bounds a git that neither
173+
/// finishes nor disconnects. Must be positive; set it very large to
174+
/// effectively disable the bound. Default: 600s (10 min), generous for large
175+
/// clones. Does not cover the ref advertisement (`info/refs`) or the
176+
/// withheld-blob fetch path (`upload_pack_excluding`, a blocking
177+
/// `spawn_blocking` a tokio timeout cannot cancel); both remain unbounded.
178+
#[arg(
179+
long,
180+
env = "GITLAWB_GIT_SERVICE_TIMEOUT_SECS",
181+
default_value_t = 600,
182+
value_parser = clap::value_parser!(u64).range(1..)
183+
)]
184+
pub git_service_timeout_secs: u64,
169185
}
170186

171187
impl Config {
@@ -183,3 +199,25 @@ impl Config {
183199
PathBuf::from(&self.key_path)
184200
}
185201
}
202+
203+
#[cfg(test)]
204+
mod tests {
205+
use super::*;
206+
207+
#[test]
208+
fn git_service_timeout_defaults_to_600_and_rejects_zero() {
209+
assert_eq!(
210+
Config::parse_from(["gitlawb-node"]).git_service_timeout_secs,
211+
600
212+
);
213+
assert_eq!(
214+
Config::parse_from(["gitlawb-node", "--git-service-timeout-secs", "30"])
215+
.git_service_timeout_secs,
216+
30
217+
);
218+
// 0 is a footgun (immediate-504 on every request); clap must reject it.
219+
assert!(
220+
Config::try_parse_from(["gitlawb-node", "--git-service-timeout-secs", "0"]).is_err()
221+
);
222+
}
223+
}

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ pub enum AppError {
4444
#[error("git error: {0}")]
4545
Git(String),
4646

47+
#[error("git service timed out: {0}")]
48+
Timeout(String),
49+
4750
#[error("database error: {0}")]
4851
Db(#[from] sqlx::Error),
4952

@@ -105,6 +108,9 @@ impl IntoResponse for AppError {
105108
(StatusCode::UNPROCESSABLE_ENTITY, "incomplete", msg.clone())
106109
}
107110
AppError::Git(msg) => (StatusCode::INTERNAL_SERVER_ERROR, "git_error", msg.clone()),
111+
// 504, distinct from the 500 git_error and from the read-gate's 404 /
112+
// the auth 401, so the client can tell a deadline from a failure.
113+
AppError::Timeout(msg) => (StatusCode::GATEWAY_TIMEOUT, "git_timeout", msg.clone()),
108114
AppError::Db(e) => (StatusCode::INTERNAL_SERVER_ERROR, "db_error", e.to_string()),
109115
AppError::Internal(e) => (
110116
StatusCode::INTERNAL_SERVER_ERROR,
@@ -123,3 +129,21 @@ impl IntoResponse for AppError {
123129
}
124130

125131
pub type Result<T> = std::result::Result<T, AppError>;
132+
133+
#[cfg(test)]
134+
mod tests {
135+
use super::*;
136+
137+
#[test]
138+
fn timeout_maps_to_504_distinct_from_git_500() {
139+
assert_eq!(
140+
AppError::Timeout("x".into()).into_response().status(),
141+
StatusCode::GATEWAY_TIMEOUT
142+
);
143+
// Guard against a swap with the generic git failure (500).
144+
assert_eq!(
145+
AppError::Git("x".into()).into_response().status(),
146+
StatusCode::INTERNAL_SERVER_ERROR
147+
);
148+
}
149+
}

0 commit comments

Comments
 (0)