From ac2ff3428462bd1de1bb0cd77b4fcdfcccb95c6b Mon Sep 17 00:00:00 2001 From: Roland Groza Date: Tue, 25 Aug 2026 10:09:43 +0900 Subject: [PATCH] feat: bound upstream git memory to avoid OOM Pass memory-bounding config on each upstream clone/fetch: core.bigFileThreshold (configurable, default 8m) streams large blobs to disk instead of loading them, and core.deltaBaseCacheLimit caps index-pack's delta-base cache. Both go via GIT_CONFIG_* alongside the auth header, so per-clone RSS stays bounded regardless of repo size. Assisted-by: Claude:claude-opus-4-8 --- README.md | 1 + src/config.rs | 8 ++++++++ src/evict.rs | 1 + src/git.rs | 56 +++++++++++++++++++++++++++++++++++++++++++++++---- src/main.rs | 1 + tests/e2e.rs | 2 ++ tests/http.rs | 2 ++ tests/lfs.rs | 1 + 8 files changed, 68 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ccc7755..582cdc3 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,7 @@ Every flag has an environment-variable equivalent. | `--max-decoded-body-mb` | `GITCACHEPROXY_MAX_DECODED_BODY_MB` | `512` | Cap on a decoded upload-pack request body, in MiB (bounds memory / gzip bombs) | | `--cache-max-mb` | `GITCACHEPROXY_CACHE_MAX_MB` | `0` | Cap on total on-disk mirror cache, in MiB; evicts least-recently-used idle mirrors when exceeded (`0` = unlimited, no eviction) | | `--git-binary` | `GITCACHEPROXY_GIT_BINARY` | `git` | Path to git | +| `--big-file-threshold` | `GITCACHEPROXY_BIG_FILE_THRESHOLD` | `8m` | Stream blobs larger than this to disk during upstream clone/fetch instead of holding them in memory, bounding `index-pack` RSS so one very large repo can't OOM the proxy | Endpoints: `/healthz`, `/readyz`, `/metrics` (Prometheus - per-repo request and upstream counters, cache-size gauges, LFS object hit/miss counters, and diff --git a/src/config.rs b/src/config.rs index f0fe357..3a1006b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -93,6 +93,14 @@ pub struct Config { #[arg(long, env = "GITCACHEPROXY_GIT_BINARY", default_value = "git")] pub git_binary: String, + /// Cap the memory an upstream `git clone`/`fetch` uses to mirror a repo: blobs + /// larger than this are streamed to disk instead of held in RAM for delta + /// resolution, so a single very large repo can't OOM the proxy. Passed to git as + /// `core.bigFileThreshold` (git's own default is 512m). Accepts git size units + /// (`8m`, `512k`, `1g`). + #[arg(long, env = "GITCACHEPROXY_BIG_FILE_THRESHOLD", default_value = "8m")] + pub big_file_threshold: String, + /// Log filter directive (e.g. `info`, `git_cache_proxy=debug,tower=warn`). /// Overridden by the `RUST_LOG` environment variable when set. #[arg(long, env = "GITCACHEPROXY_LOG", default_value = "info")] diff --git a/src/evict.rs b/src/evict.rs index 9f935b3..ba1768d 100644 --- a/src/evict.rs +++ b/src/evict.rs @@ -610,6 +610,7 @@ mod tests { GitConfig { git_binary: "git".into(), upstream_auth_header: None, + big_file_threshold: "8m".into(), fetch_ttl: Duration::from_secs(10), } } diff --git a/src/git.rs b/src/git.rs index 9627f46..d6a0676 100644 --- a/src/git.rs +++ b/src/git.rs @@ -48,6 +48,10 @@ pub struct GitConfig { /// Optional header for upstream auth, e.g. `Authorization: Bearer `. /// Injected via env (not argv) so it never shows up in `ps`. Never logged. pub upstream_auth_header: Option, + /// git `core.bigFileThreshold` for upstream clone/fetch: blobs above it are + /// streamed to disk rather than held in memory, bounding `index-pack` RSS on + /// very large repos. See `Config::big_file_threshold`. + pub big_file_threshold: String, /// Skip the upstream fetch if the mirror was refreshed within this window. pub fetch_ttl: Duration, } @@ -357,10 +361,11 @@ impl GitCache { fn fetch_cmd(&self) -> Command { let mut c = Command::new(&self.cfg.git_binary); c.env("GIT_TERMINAL_PROMPT", "0"); // fail instead of hanging on a prompt - if let Some(h) = &self.cfg.upstream_auth_header { - c.env("GIT_CONFIG_COUNT", "1") - .env("GIT_CONFIG_KEY_0", "http.extraHeader") - .env("GIT_CONFIG_VALUE_0", h); + for (k, v) in git_config_env( + &self.cfg.big_file_threshold, + self.cfg.upstream_auth_header.as_deref(), + ) { + c.env(k, v); } c } @@ -425,6 +430,29 @@ impl Drop for TimedReader { } } +/// Assemble the `GIT_CONFIG_*` environment for an upstream git command: the +/// memory-bounding options, then the optional auth header, numbered as git's +/// env-based config protocol requires (`GIT_CONFIG_COUNT` + `KEY_i`/`VALUE_i`). +/// Everything goes via env, not argv, so the auth header never shows up in `ps`. +/// `core.bigFileThreshold` streams large blobs to disk instead of holding them in +/// memory, and `core.deltaBaseCacheLimit` caps index-pack's delta-base cache - so a +/// single very large repo's clone cannot balloon RSS and OOM the proxy. +fn git_config_env(big_file_threshold: &str, auth_header: Option<&str>) -> Vec<(String, String)> { + let mut pairs: Vec<(&str, &str)> = vec![ + ("core.bigFileThreshold", big_file_threshold), + ("core.deltaBaseCacheLimit", "128m"), + ]; + if let Some(h) = auth_header { + pairs.push(("http.extraHeader", h)); + } + let mut env = vec![("GIT_CONFIG_COUNT".to_string(), pairs.len().to_string())]; + for (i, (k, v)) in pairs.into_iter().enumerate() { + env.push((format!("GIT_CONFIG_KEY_{i}"), k.to_string())); + env.push((format!("GIT_CONFIG_VALUE_{i}"), v.to_string())); + } + env +} + /// Encode a string as a single pkt-line (4-hex length prefix + payload). fn pkt_line(s: &str) -> Vec { let mut v = format!("{:04x}", s.len() + 4).into_bytes(); @@ -436,6 +464,26 @@ fn pkt_line(s: &str) -> Vec { mod tests { use super::*; + #[test] + fn git_config_env_numbers_options_and_appends_auth() { + // No auth: just the two memory bounds, numbered from 0. + let env = git_config_env("8m", None); + assert!(env.contains(&("GIT_CONFIG_COUNT".into(), "2".into()))); + assert!(env.contains(&("GIT_CONFIG_KEY_0".into(), "core.bigFileThreshold".into()))); + assert!(env.contains(&("GIT_CONFIG_VALUE_0".into(), "8m".into()))); + assert!(env.contains(&("GIT_CONFIG_KEY_1".into(), "core.deltaBaseCacheLimit".into()))); + + // With auth: appended as the last numbered entry, count bumps to 3. + let env = git_config_env("16m", Some("Authorization: Basic xyz")); + assert!(env.contains(&("GIT_CONFIG_COUNT".into(), "3".into()))); + assert!(env.contains(&("GIT_CONFIG_VALUE_0".into(), "16m".into()))); + assert!(env.contains(&("GIT_CONFIG_KEY_2".into(), "http.extraHeader".into()))); + assert!(env.contains(&( + "GIT_CONFIG_VALUE_2".into(), + "Authorization: Basic xyz".into() + ))); + } + #[test] fn pkt_line_encodes_length() { assert_eq!(pkt_line("a"), b"0005a"); diff --git a/src/main.rs b/src/main.rs index 2e21204..4f194ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,6 +50,7 @@ async fn main() -> Result<()> { let git_cfg = git::GitConfig { git_binary: cfg.git_binary.clone(), upstream_auth_header: upstream_auth_header.clone(), + big_file_threshold: cfg.big_file_threshold.clone(), fetch_ttl: Duration::from_secs(cfg.fetch_ttl_seconds), }; diff --git a/tests/e2e.rs b/tests/e2e.rs index f17235f..39a5127 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -86,6 +86,7 @@ async fn upload_pack_decodes_gzip_encoded_request() { let cfg = GitConfig { git_binary: "git".into(), upstream_auth_header: None, + big_file_threshold: "8m".into(), fetch_ttl: Duration::from_secs(0), }; let state = AppState { @@ -176,6 +177,7 @@ async fn clones_through_proxy_serves_all_refs_and_rejects_push() { let cfg = GitConfig { git_binary: "git".into(), upstream_auth_header: None, + big_file_threshold: "8m".into(), fetch_ttl: Duration::from_secs(0), }; // Eviction enabled with an effectively unbounded cap: no mirror is ever diff --git a/tests/http.rs b/tests/http.rs index 98f1a6a..f4e9724 100644 --- a/tests/http.rs +++ b/tests/http.rs @@ -24,6 +24,7 @@ fn state(serve_token: Option) -> AppState { let cfg = GitConfig { git_binary: "git".into(), upstream_auth_header: None, + big_file_threshold: "8m".into(), fetch_ttl: Duration::from_secs(10), }; let lfs = Arc::new(Lfs::new( @@ -211,6 +212,7 @@ async fn upstream_failure_returns_bad_gateway_and_records_error() { let cfg = GitConfig { git_binary: "git".into(), upstream_auth_header: None, + big_file_threshold: "8m".into(), fetch_ttl: Duration::from_secs(10), }; let lfs = Arc::new(Lfs::new( diff --git a/tests/lfs.rs b/tests/lfs.rs index 7f9380c..4de5aa3 100644 --- a/tests/lfs.rs +++ b/tests/lfs.rs @@ -336,6 +336,7 @@ fn proxy_state(addr: SocketAddr, cache: &std::path::Path, metrics: Arc) let cfg = GitConfig { git_binary: "git".into(), upstream_auth_header: None, + big_file_threshold: "8m".into(), fetch_ttl: Duration::from_secs(10), }; AppState {