Files
alkssh/tests/common/git_doubles.rs
T
glm-5.3-flash 9cf5bfbaf3 fix(git): status-before-close by construction — the split-halves seam duplex (w4-git-exit-close)
The wave-3 gate's booked finding 1: the git row's exit-report order
was honored by scheduling, not by construction — into_stream()'s
ChannelCloseOnDrop spawned a CHANNEL_CLOSE send racing the
exit_status_request send for a queue position on russh's bounded
message queue (event_buffer_size, default 10); a full buffer at the
serve tail could silently no-op the report.

- the seam now rides a split-halves duplex built from russh 0.65's
  public API (its ChannelRx/ChannelTx are private): the reader is
  git_map's ChannelHalveReader (owned read half, the private rx's
  observation semantics repinned, no Drop path ever messages the
  wire) and the writer the write half's ChannelTx via
  make_writer_ext (poll_shutdown still sends EOF; the Rust 2024
  capture phantom erased through a trait object)
- the report's send is now the sole close-contracting sender,
  ordered ahead of the explicit close by sequential awaited sends on
  the one handle — saturated buffer or not
- new wire row backlogged_seam_tail_still_reports_exit_status_before_close:
  a volume flood freezes the armed client loop (the new harness
  DataGate + the client data hook russh awaits inline) so the server's
  flush pends and the bounded message queue saturates at the serve
  tail; the gate's release drives the tail through the unwinding
  contention — the row pins exit-status(0) before close under the
  saturation and falsifies the old shape deterministically (15/15 red
  under a temporary into_stream() revert, 10/10 green with the fix)
- doubles gain a race-safe per-repo serve gate (install_gate, the
  double-check pattern over list_refs); harness gains
  drive_with_data_gate (the drive* bodies factored into
  mount_and_connect, existing signatures unchanged)
- all existing git wire rows unchanged and green (wire order
  identical when the race does not fire); spawn-and-return intact;
  tty rows untouched; event_buffer_size stays russh's default
  (pinned in the row)

Verification: cargo test (default), --all-features, and six mixed
per-feature builds green; clippy x2 -D warnings; fmt -check;
cargo doc --no-deps 0 warnings; taskgraph validate (35 tasks).
2026-10-29 07:01:33 +00:00

341 lines
10 KiB
Rust

//! alkssh's git-seam doubles (`w3-git-mapping`) — the hand-rolled
//! in-memory alkgit backend quartet, shaped per
//! `alkgit/src/backend.rs:164-254`'s noop impls (the shapes that file
//! itself pins for dyn compatibility), with recording accessors the
//! rows assert through. alkgit's own `Mem*` constructions are
//! test-private to that crate — this module hand-rolls the minimal
//! surface the git rows need, never a sibling-checkout path, never a
//! copy of alkgit's doubles' *code*.
//!
//! State/accessor split: the doubles are configurable per row (repo
//! records with visibility + grants; advertised ref lines) and
//! record every seam call the serving path makes (resolutions,
//! applied CAS updates, prepared pushes' updates, generated packs) —
//! the dispatch fidelity (which id was resolved, which updates were
//! applied) is observable without alkgit's own test surface.
//!
//! Feature gating: this module is `git`-gated at the include site
//! (`tests/common/mod.rs`) — a non-git build compiles none of it, and
//! alkgit types never appear outside.
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
/// The seam's ref-line shape re-exported for the rows (`RefLine` is
/// the doubles' `list_refs` payload).
pub use alkgit::RefLine;
use alkgit::{
AccessAction, GitPackGen, GitPackIngest, GitRefs, GitRegistry, PreparedPush, PushOptions,
RefOutcome, RefUpdate, RegistryError, RepoRecord, StorageError, Visibility,
};
use async_trait::async_trait;
use gix_hash::ObjectId;
use parking_lot::{Mutex, RwLock};
/// A test oid — deterministic bytes (the row-assertable hex shape).
pub fn oid(byte: u8) -> ObjectId {
ObjectId::from([byte; 20])
}
/// One repo the registry double serves: grants keyed by the resolved
/// identity id (alkgit's `authorize` input — the identity-ride
/// witness), the advertised ref set the refs double lists.
pub struct RepoSetup {
pub repo_id: String,
pub visibility: Visibility,
pub grants: BTreeMap<String, Vec<AccessAction>>,
pub refs: Vec<RefLine>,
}
impl RepoSetup {
pub fn private_granted(repo_id: &str, identity_id: &str, refs: Vec<RefLine>) -> Self {
let mut grants = BTreeMap::new();
grants.insert(
identity_id.to_string(),
vec![AccessAction::Read, AccessAction::Write],
);
Self {
repo_id: repo_id.to_string(),
visibility: Visibility::Private,
grants,
refs,
}
}
pub fn granted(
repo_id: &str,
identity_id: &str,
actions: Vec<AccessAction>,
refs: Vec<RefLine>,
) -> Self {
let mut grants = BTreeMap::new();
grants.insert(identity_id.to_string(), actions);
Self {
repo_id: repo_id.to_string(),
visibility: Visibility::Private,
grants,
refs,
}
}
pub fn private_ungranted(repo_id: &str, refs: Vec<RefLine>) -> Self {
Self {
repo_id: repo_id.to_string(),
visibility: Visibility::Private,
grants: BTreeMap::new(),
refs,
}
}
}
/// One recorded CAS application (the wire layer's single ingestion
/// home, `GitRefs::apply_updates`).
#[derive(Clone, Debug)]
pub struct AppliedUpdates {
pub repo_id: String,
pub updates: Vec<RefUpdate>,
pub atomic: bool,
}
/// One recorded pack ingestion (`GitPackIngest::prepare`).
#[derive(Clone, Debug)]
pub struct PreparedPack {
pub repo_id: String,
pub updates: Vec<RefUpdate>,
pub push_options: Option<PushOptions>,
}
/// One recorded pack generation (`GitPackGen::generate`).
#[derive(Clone, Debug)]
pub struct GeneratedPack {
pub repo_id: String,
pub wants: Vec<ObjectId>,
pub boundary_haves: Vec<ObjectId>,
}
/// One per-repo serve gate (the `w4-git-exit-close` row's
/// serve-timing control): the double's `GitRefs::list_refs` for the
/// gated repo id pends on it until [`MemGate::release`] — the serving
/// task stalls at that call with its channel's reader parked, so the
/// row schedules the serve's later stages (the answer flood, the
/// serve tail) against a prepared wire state. Race-safe by
/// construction (the double-check pattern over the released flag).
pub struct MemGate {
released: AtomicBool,
opened: tokio::sync::Notify,
}
impl MemGate {
fn new() -> Self {
Self {
released: AtomicBool::new(false),
opened: tokio::sync::Notify::new(),
}
}
/// Releases the gate's waiting serve.
pub fn release(&self) {
self.released.store(true, Ordering::Release);
self.opened.notify_waiters();
}
async fn wait(&self) {
if self.released.load(Ordering::Acquire) {
return;
}
let notified = self.opened.notified();
if self.released.load(Ordering::Acquire) {
return;
}
notified.await;
}
}
/// The in-memory quartet: one struct, four trait impls — the
/// [`GitSeam`](alkssh::GitSeam) takes a separate `Arc` per backend
/// trait and the rows share this state.
#[derive(Default)]
pub struct MemGit {
repos: RwLock<RepoRegistry>,
refs: RwLock<HashMap<String, Vec<RefLine>>>,
resolves: Mutex<Vec<String>>,
applied: Mutex<Vec<AppliedUpdates>>,
prepared: Mutex<Vec<PreparedPack>>,
generated: Mutex<Vec<GeneratedPack>>,
gates: Mutex<HashMap<String, Arc<MemGate>>>,
}
impl MemGit {
pub fn new() -> Self {
Self::default()
}
/// Installs one repo record + advertised ref set.
pub fn install(&self, setup: RepoSetup) {
self.repos
.write()
.insert(setup.repo_id.clone(), (setup.visibility, setup.grants));
self.refs.write().insert(setup.repo_id.clone(), setup.refs);
}
/// Every `GitRegistry::resolve` id, in call order.
pub fn resolved_ids(&self) -> Vec<String> {
self.resolves.lock().clone()
}
/// Every CAS application, in call order.
pub fn applied(&self) -> Vec<AppliedUpdates> {
self.applied.lock().clone()
}
/// Every pack preparation, in call order.
pub fn prepared(&self) -> Vec<PreparedPack> {
self.prepared.lock().clone()
}
/// Every pack generation, in call order.
pub fn generated(&self) -> Vec<GeneratedPack> {
self.generated.lock().clone()
}
/// Installs a serve gate for one repo id — [`GitRefs::list_refs`]
/// calls for it pend until the returned gate's `release`. The
/// rows use the gate to prepare wire state (saturated queues,
/// blocked clients) for the serve's later stages.
pub fn install_gate(&self, repo_id: &str) -> Arc<MemGate> {
let gate = Arc::new(MemGate::new());
self.gates
.lock()
.insert(repo_id.to_string(), Arc::clone(&gate));
gate
}
}
/// The record for a resolved id — the RepoRecord the seam hands the
/// backend traits.
fn record(id: &str, repos: &RepoRegistry) -> Result<RepoRecord, RegistryError> {
match repos.get(id) {
Some((visibility, grants)) => Ok(RepoRecord {
repo_id: id.to_string(),
visibility: *visibility,
grants: grants.clone(),
}),
None => Err(RegistryError::NotFound),
}
}
/// The registry's per-repo record: visibility + the grant map keyed
/// by the resolved identity id.
type RepoRegistry = HashMap<String, (Visibility, BTreeMap<String, Vec<AccessAction>>)>;
#[async_trait]
impl GitRegistry for MemGit {
async fn resolve(&self, repo_id: &str) -> Result<RepoRecord, RegistryError> {
self.resolves.lock().push(repo_id.to_string());
record(repo_id, &self.repos.read())
}
}
#[async_trait]
impl GitRefs for MemGit {
async fn list_refs(&self, repo: &RepoRecord) -> Result<Vec<RefLine>, StorageError> {
let gate = self.gates.lock().get(&repo.repo_id).map(Arc::clone);
if let Some(gate) = gate {
gate.wait().await;
}
Ok(self
.refs
.read()
.get(&repo.repo_id)
.cloned()
.unwrap_or_default())
}
async fn apply_updates(
&self,
repo: &RepoRecord,
updates: Vec<RefUpdate>,
atomic: bool,
) -> Result<Vec<RefOutcome>, StorageError> {
let applied = AppliedUpdates {
repo_id: repo.repo_id.clone(),
updates: updates.clone(),
atomic,
};
let outcomes = updates
.iter()
.map(|update| RefOutcome {
name: update.name.clone(),
result: Ok(()),
})
.collect();
self.applied.lock().push(applied);
Ok(outcomes)
}
}
#[async_trait]
impl GitPackGen for MemGit {
async fn generate(
&self,
repo: &RepoRecord,
wants: Vec<ObjectId>,
boundary_haves: Vec<ObjectId>,
_limits: alkgit::Limits,
_sink: Box<dyn std::io::Write + Send>,
) -> Result<(), StorageError> {
self.generated.lock().push(GeneratedPack {
repo_id: repo.repo_id.clone(),
wants,
boundary_haves,
});
Ok(())
}
async fn common_haves(
&self,
_repo: &RepoRecord,
_haves: Vec<ObjectId>,
) -> Result<Vec<ObjectId>, StorageError> {
Ok(Vec::new())
}
}
#[async_trait]
impl GitPackIngest for MemGit {
async fn prepare(
&self,
repo: &RepoRecord,
updates: Vec<RefUpdate>,
_pack: Box<dyn std::io::Read + Send>,
push_options: Option<PushOptions>,
_limits: alkgit::Limits,
) -> Result<PreparedPush, StorageError> {
self.prepared.lock().push(PreparedPack {
repo_id: repo.repo_id.clone(),
updates: updates.clone(),
push_options,
});
Ok(PreparedPush {
unpack: Ok(()),
updates,
})
}
}
/// Assembles the recording quartet into a
/// [`GitSeam`](alkssh::GitSeam) — one shared `Arc<MemGit>`, the
/// default alkgit budget, the push-options gate off (the v1 default).
pub fn git_seam(mem: &Arc<MemGit>) -> alkssh::GitSeam {
alkssh::GitSeam {
limits: alkgit::Limits::default(),
registry: Arc::clone(mem) as _,
refs: Arc::clone(mem) as _,
packgen: Arc::clone(mem) as _,
ingest: Arc::clone(mem) as _,
push_options_negotiated: false,
pipeline: Arc::new(tokio::sync::Semaphore::new(2)),
}
}