fix(node): bound concurrent post-push encryption walks with an admission pool (#174)

After a successful path-scoped push the handler released its write permit and
then ran a DETACHED tokio task whose spawn_blocking(withheld_blob_recipients_bounded)
performed another full-history git walk under no admission. N fast completed
pushes spawned N concurrent full-history walks past GITLAWB_MAX_CONCURRENT_GIT_PUSHES
(which bounds only the in-handler phase), and the per-child bounding from 71389e6
caps each walk's duration but not their count (P1-e).

Add git_encrypt_semaphore, a pool of its own (sized from max_concurrent_git_pushes,
no new knob — Q1) so a long background walk never holds a foreground write slot and
a handler holding a write permit can't self-deadlock. Route the walk through a new
withheld_recipients_gated helper that acquires the pool before the walk and DEFERS
(blocks) when full rather than shedding — dropping the walk would lose the
withheld-blob recovery copy, so durability stays fail-closed.

Regression: encrypt_walk_defers_when_pool_exhausted drives the gated helper with the
pool exhausted and asserts the walk blocks and does not run its rev-list, then runs
once a permit frees. RED before (the walk runs regardless of the pool), GREEN after.
The detached push task calls this exact helper.
This commit is contained in:
t committed 2026-07-14 20:28:52 -05:00
1 parent 12643573f6
commit 2a54c159e6
5 files changed
+144 -13

No files matched your search

+125 -13
View File
@@ -727,6 +727,37 @@ fn acquire_read_caller_permit(
}
}
/// Acquire an encryption-walk admission permit, then run the bounded withheld-blob
/// recipients walk. Blocks (defers) when `git_encrypt_semaphore` is full rather than
/// shedding — the walk is background so added latency is fine, and dropping it would
/// lose the withheld-blob recovery copy (#174 P1-e). Bounds the number of concurrent
/// post-push encryption walks so N fast completed pushes cannot spawn N concurrent
/// full-history git walks. Mirrors the original `spawn_blocking(...).await` return
/// shape so the caller's `Ok(Ok(recipients))` match is unchanged.
async fn withheld_recipients_gated(
encrypt_sem: std::sync::Arc<tokio::sync::Semaphore>,
repo_path: std::path::PathBuf,
git_bin: String,
timeout: std::time::Duration,
rules: Vec<crate::db::VisibilityRule>,
is_public: bool,
owner_did: String,
) -> std::result::Result<
anyhow::Result<std::collections::HashMap<String, std::collections::BTreeSet<String>>>,
tokio::task::JoinError,
> {
let _permit = encrypt_sem
.acquire_owned()
.await
.expect("git_encrypt_semaphore is never closed");
tokio::task::spawn_blocking(move || {
crate::git::visibility_pack::withheld_blob_recipients_bounded(
&repo_path, &git_bin, timeout, &rules, is_public, &owner_did,
)
})
.await
}
/// Map an error from a `smart_http` git service call to the right `AppError`:
/// [`smart_http::GitServiceTimeout`] to 504, a malformed client request to 400,
/// anything else to a 500 git error. Pure (no logging) so it is unit-testable;
@@ -1329,6 +1360,7 @@ pub async fn git_receive_pack(
let repo_name = record.name.clone();
let enc_git_bin = state.git_bin.clone();
let enc_timeout = std::time::Duration::from_secs(state.config.git_service_timeout_secs);
let encrypt_sem = state.git_encrypt_semaphore.clone();
tokio::spawn(async move {
let pinned = crate::ipfs_pin::pin_new_objects(
&ipfs_api,
@@ -1351,19 +1383,18 @@ pub async fn git_receive_pack(
// the has_path_scoped_rule gate on the other two withheld-walk sites.
if let Some(rules) = rules_for_enc.filter(|r| visibility_pack::has_path_scoped_rule(r))
{
let p = repo_path_clone.clone();
let owner = owner_did.clone();
let git_bin = enc_git_bin.clone();
let recip = tokio::task::spawn_blocking(move || {
crate::git::visibility_pack::withheld_blob_recipients_bounded(
&p,
&git_bin,
enc_timeout,
&rules,
is_public,
&owner,
)
})
// Bound the number of concurrent post-push encryption walks (#174 P1-e):
// acquire an admission permit before the full-history walk, deferring
// when the pool is full rather than shedding the recovery pin.
let recip = withheld_recipients_gated(
encrypt_sem.clone(),
repo_path_clone.clone(),
enc_git_bin.clone(),
enc_timeout,
rules,
is_public,
owner_did.clone(),
)
.await;
if let Ok(Ok(recipients)) = recip {
let delta = crate::encrypted_pin::encrypt_and_pin(
@@ -3699,6 +3730,87 @@ mod tests {
);
}
/// #174 U5 (P1-e, RED-before/GREEN-after): the post-push encryption walk acquires a
/// `git_encrypt_semaphore` permit before running, so completed pushes cannot spawn
/// unbounded concurrent full-history walks. With the pool exhausted the gated walk
/// must DEFER (block on admission) and NOT run its rev-list; on the pre-fix code
/// (no acquire) the walk runs regardless of the pool (RED). It defers rather than
/// sheds — releasing the permit lets the SAME walk run and pin (durability stays
/// fail-closed). Exercises the gating seam directly; the detached push task calls
/// this exact helper.
#[tokio::test]
async fn encrypt_walk_defers_when_pool_exhausted() {
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Semaphore;
let tmp = tempfile::TempDir::new().unwrap();
let marker = tmp.path().join("revlist.ran");
// Fake git records when rev-list runs (the walk's first git call).
let body = format!(
"#!/bin/sh\ncase \"$1\" in\n rev-list) echo ran > \"{}\" ;;\n *) : ;;\nesac\nexit 0\n",
marker.display()
);
let git_path = tmp.path().join("fakegit");
std::fs::write(&git_path, &body).unwrap();
{
use std::os::unix::fs::PermissionsExt;
let mut perm = std::fs::metadata(&git_path).unwrap().permissions();
perm.set_mode(0o755);
std::fs::set_permissions(&git_path, perm).unwrap();
}
let git_bin = git_path.to_str().unwrap().to_string();
let owner = "did:key:z6MkEncWalkOwnerAAAAAAAAAAAAAAAAAAAAAAAA".to_string();
// Exhaust the pool: hold its only permit so a gated walk must defer.
let sem = Arc::new(Semaphore::new(1));
let held = sem.clone().acquire_owned().await.unwrap();
// Blocked: the gated walk must NOT complete or run rev-list while exhausted.
let blocked = tokio::time::timeout(
Duration::from_millis(500),
withheld_recipients_gated(
sem.clone(),
tmp.path().to_path_buf(),
git_bin.clone(),
Duration::from_secs(5),
Vec::new(),
true,
owner.clone(),
),
)
.await;
assert!(
blocked.is_err(),
"the encryption walk must defer (block on admission) when the pool is exhausted"
);
assert!(
!marker.exists(),
"the walk's rev-list must not run while its admission permit is unavailable (P1-e)"
);
// Release admission: the SAME walk now runs (defer, not shed) — rev-list fires.
drop(held);
let ran = withheld_recipients_gated(
sem,
tmp.path().to_path_buf(),
git_bin,
Duration::from_secs(5),
Vec::new(),
true,
owner,
)
.await;
assert!(
ran.is_ok(),
"with a permit the walk runs and joins: {ran:?}"
);
assert!(
marker.exists(),
"once admission is available the deferred walk runs its rev-list"
);
}
/// #174 SC2 (per-source key, U1): the per-caller read sub-cap keys on the
/// resolved source IP, NOT the signed DID, so a disposable-DID farm cannot
/// multiply its budget. Fill the source IP's single read slot, then drive two
+1
View File
@@ -523,6 +523,7 @@ mod tests {
git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_push_advert_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_encrypt_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_read_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(16),
git_push_advert_per_caller:
crate::rate_limit::PerCallerConcurrency::with_default_max_keys(8),
+6
View File
@@ -389,6 +389,12 @@ async fn main() -> Result<()> {
git_push_advert_semaphore: Arc::new(tokio::sync::Semaphore::new(
config.max_concurrent_git_pushes,
)),
// Bounds concurrent detached post-push encryption walks, sized from the push
// pool (no separate knob — Q1): completed pushes cannot outnumber active
// encryption walks past this (#174 P1-e).
git_encrypt_semaphore: Arc::new(tokio::sync::Semaphore::new(
config.max_concurrent_git_pushes,
)),
git_read_per_caller: rate_limit::PerCallerConcurrency::with_default_max_keys(
config.max_concurrent_reads_per_caller,
),
+11
View File
@@ -110,6 +110,17 @@ pub struct AppState {
/// advert pool (each source also capped by `git_push_advert_per_caller` and the
/// per-IP push rate limiter), and the reserved POST pool is untouched (#174).
pub git_push_advert_semaphore: Arc<tokio::sync::Semaphore>,
/// Bounds concurrent post-push encrypt-then-pin history walks. Each successful
/// path-scoped push releases its handler write permit and then runs a DETACHED
/// full-history walk (`withheld_blob_recipients_bounded`) to seal withheld blobs;
/// without a cap, N fast pushes spawn N concurrent full-history git walks past
/// `max_concurrent_git_pushes` (which only bounds the in-handler phase). The walk
/// acquires a permit here and DEFERS (blocks) when the pool is full rather than
/// shedding — the work is background and dropping it would lose the recovery copy
/// (#174 P1-e). A pool of its own, not `git_write_semaphore`: a long background
/// walk must not hold a foreground write slot, and a handler already holding a
/// write permit that needed a second would self-deadlock at pool size 1.
pub git_encrypt_semaphore: Arc<tokio::sync::Semaphore>,
/// Per-caller concurrency sub-cap on the read pool: each caller (keyed on the
/// resolved source IP, #174 U1) may hold at most `max_concurrent_reads_per_caller`
/// in-flight read ops, so one caller cannot monopolize `git_read_semaphore`
+1
View File
@@ -86,6 +86,7 @@ fn build_state(db: Arc<crate::db::Db>, pool: PgPool) -> AppState {
git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_push_advert_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_encrypt_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_read_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(16),
git_push_advert_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(
8,