gunnar-server 3.0.0

The gunnar git server: SSH and smart-HTTP git transports and the gRPC control plane. The appliance half, extracted from gunnar-cli so the server code lives in its own crate.
Documentation
//! **One store handle per store root, per process — the thing both engines need
//! and only one of them had.**
//!
//! # The defect this exists to make unrepresentable
//!
//! A storage engine's reading path and its ingest path are two *call sites* and
//! must not be two *handles*. Both engines keep a view of what is on disk —
//! znippy an archive tail, gix a `PackSet` and an odb pool whose handles are
//! `refresh_never()` — and each engine refreshes that view **on the instance
//! that did the write**. So a `put_pack` through a second, throwaway handle
//! refreshes the throwaway and drops it, and the handle the server is actually
//! serving from never learns that a pack arrived.
//!
//! znippy routed both paths through one cached handle from the start. gix did
//! not: [`open`](super::gix_engine::open) and
//! [`ingest`](super::gix_engine::ingest) each called `GixStore::open_or_init`.
//! MEASURED on the reference desktop 2026-08-11, one `gunnar-server` process on `engine-gix`:
//!
//! ```text
//! push   → succeeds
//! clone  → remote: the repository is unavailable; try again
//!          server log: <oid> is a LOOSE object: it is in this repository but
//!          has no pack entry, so there are no stored bytes to copy
//! ```
//!
//! …while on disk that repository held **0 loose objects and 1 pack**, and
//! `git verify-pack -v` found the very oid in it and called the pack `ok`.
//! Restarting the server — which is nothing but a fresh handle — served the
//! same bytes perfectly. It cost eleven arms of a two-engine sweep, across two
//! independent transports, and it is invisible to any test that pushes in one
//! process and reads in another.
//!
//! # Why a shared helper rather than a second copy of znippy's
//!
//! One implementation. The lookup-or-construct-under-the-lock dance is ~12 lines and getting
//! it subtly different in two engines is exactly how the two views drift apart
//! again. This is the *shape*; each engine keeps its own map, its own store type
//! and its own constructor, because those are genuinely different.
//!
//! **Opened under the lock, deliberately.** An ordinary read-through cache
//! constructs outside the lock and discards the loser of a race. Here the loser
//! is a second live handle over one directory — the very thing this module
//! exists to prevent — so a race must not be able to create one even
//! transiently.

use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, Weak};

use anyhow::Result;

/// The map one engine caches its handles in.
///
/// **`Weak`, not `Arc`, and that is the whole memory story.** See
/// [`shared`] — a strong map here is immortal, and it made the bound
/// `ServerStores` already enforces do nothing.
pub(super) type Cache<S> = OnceLock<Mutex<HashMap<PathBuf, Weak<S>>>>;

/// Lock a cache, treating poisoning as ordinary contention.
///
/// A poisoned lock here means some other thread panicked while holding it; the
/// map itself is a `HashMap<PathBuf, Arc<_>>` and cannot be left torn by that,
/// so recovering the guard is correct and refusing every subsequent request
/// would turn one panic into a dead server.
fn lock<S>(cache: &'static Cache<S>) -> MutexGuard<'static, HashMap<PathBuf, Weak<S>>> {
    cache
        .get_or_init(Default::default)
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner())
}

/// **The handle for `root`, shared with every other live user of it.**
///
/// `make` runs on a miss, and it runs **while the lock is held** — see the
/// module docs for why that is the point rather than an oversight.
///
/// # Why the map holds `Weak` — MEASURED 2026-08-11
///
/// A strong map is an immortal one: nothing ever removes an entry, so every
/// repository a process has *ever* opened stays fully resident until exit. That
/// is not free. znippy's store carries a redb page cache of
/// `DEFAULT_REDB_CACHE_BYTES` = **64 MiB, one database per repository**
/// (corrected 2026-08-31: this said "two databases per repository" and it was
/// stale — `git_ops::open_with_arms` opens exactly one `ObjectReadStack` and
/// `znippy-plugin-git`'s `tests/fd_per_store.rs` counts exactly one
/// `objects.tail` descriptor. The 2026-08-31 reading took the doubled figure
/// from here; the 382 B/object measurement refuted it). On the reference desktop the forge
/// suite seeds
/// ~20 repositories and gunnar-on-znippy reported **4815–4842 MB of anonymous
/// RSS — flat**, whether the clone it then served was 3 257 objects or 352 396.
/// A figure that does not move with the workload is not the workload. gix, over
/// the same twenty repositories, sat at 176 MB.
///
/// The bound already exists one layer up and this was defeating it:
/// [`ServerStores`](crate::stores::ServerStores) caches `Arc<OpenRepo>` in a
/// [`DEFAULT_OPEN_REPOS`](crate::stores::DEFAULT_OPEN_REPOS)-entry LRU and
/// evicts, but evicting the `OpenRepo` freed nothing while this map still held
/// the store underneath it. `Weak` hands the lifetime back to that LRU: a store
/// lives exactly as long as some `OpenRepo` holds it, and the eviction that was
/// already happening now actually reclaims.
///
/// # It does NOT weaken the correctness fix this module exists for
///
/// The defect was a *reader opened before a write* not seeing it, and a reader
/// that predates a write is by definition still alive — so it holds an `Arc`,
/// the `Weak` upgrades to that same instance, and `ingest` writes through the
/// handle the reader is serving from. Unchanged.
///
/// When nothing holds it, the upgrade fails and `make` builds a fresh store —
/// which loads a fresh view off disk and is therefore *also* correct, just not
/// shared. There is no window in which two live handles disagree, which is the
/// only thing the strong map was buying.
pub(super) fn shared<S>(
    cache: &'static Cache<S>,
    root: PathBuf,
    make: impl FnOnce() -> Result<S>,
) -> Result<Arc<S>> {
    let mut map = lock(cache);
    if let Some(store) = map.get(&root).and_then(Weak::upgrade) {
        return Ok(store);
    }
    let store = Arc::new(make()?);
    map.insert(root, Arc::downgrade(&store));
    // Dead entries are 24-ish bytes each and only accumulate for repositories
    // that were opened and released. Swept here rather than on a timer: this is
    // the one place that already holds the lock.
    map.retain(|_, w| w.strong_count() > 0);
    Ok(store)
}

/// The live store for `root`, if some handle keeps it alive. Never opens one:
/// a store nobody holds has no worker to cancel and no progress to report.
pub(super) fn peek<S>(cache: &'static Cache<S>, root: &std::path::Path) -> Option<Arc<S>> {
    lock(cache).get(root).and_then(Weak::upgrade)
}

/// Drop every handle this cache holds.
///
/// Called from [`super::shutdown`] on the way out. What it buys differs per
/// engine — znippy needs it so `redb`'s clean-shutdown `Drop` runs at all, gix
/// only releases mmaps — but the *shape* is one line either way, which is why
/// it is here and not written twice.
pub(super) fn clear<S>(cache: &'static Cache<S>) {
    lock(cache).clear();
}

/// **Every store some handle keeps alive**, as `(root, handle)`. Never opens
/// one: what this answers is "what is this process paying RAM for right now",
/// and a store nobody holds costs nothing and must not be reported as if it
/// did. The `Weak` map is swept on the way, like [`shared`] does.
pub(super) fn live<S>(cache: &'static Cache<S>) -> Vec<(PathBuf, Arc<S>)> {
    let mut map = lock(cache);
    map.retain(|_, w| w.strong_count() > 0);
    map.iter()
        .filter_map(|(root, w)| w.upgrade().map(|s| (root.clone(), s)))
        .collect()
}