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).
This commit is contained in:
glm-5.3-flash committed 2026-10-29 07:01:33 +00:00
1 parent dba64d65d3
commit 9cf5bfbaf3
5 files changed
+614 -15

No files matched your search

+189 -9
View File
@@ -40,6 +40,33 @@
//! evidence, contract-pinned by the denial rows in
//! `tests/git_mapping.rs`.
//!
//! - **Status-before-close by construction** (the wave-3 gate's
//! booked finding 1, `w4-git-exit-close`): the seam's stream is
//! built from the channel's **split halves**, never
//! `Channel::into_stream()`. `into_stream` wraps the whole channel
//! in russh's `ChannelCloseOnDrop`, whose `Drop` sends
//! server→client `CHANNEL_CLOSE` from a *spawned task* — inside the
//! seam call, racing [`serve_git_row`]'s explicit
//! `exit_status_request` for a queue position on the same bounded
//! `Sender<Msg>` (russh's `event_buffer_size`, default 10): a full
//! buffer at the serve tail could enqueue the implicit close first,
//! let the loop remove the channel, and silently no-op the report.
//! The split-halves shape removes the competitor instead of racing
//! it: russh 0.65 keeps its `ChannelRx`/`ChannelTx` private, so
//! [`GitChannelDuplex`] is built from the public API — the reader
//! is the module's [`ChannelHalveReader`] (an owned read half
//! repinning the private `ChannelRx`'s observation semantics
//! without the close-on-drop wrapper; its only `Drop` behavior
//! differs from `into_stream`'s), the writer the write half's
//! `ChannelTx` via `make_writer_ext` (its `poll_shutdown` still
//! sends EOF, exactly `into_stream`'s). Nothing the seam drops ever
//! messages the wire, so the report's send is the sole
//! close-contracting sender, and its queue position ahead of the
//! explicit close holds by construction — sequential awaited sends
//! on the one `Handle`, saturated buffer or not. The tty rows are
//! not in this class: their report precedes any close-contracting
//! drop within one task — the wave-3 gate verified.
//!
//! - **Exit-status mapping** (POC-3 §A.5, decided):
//! [`serve_git_row`] maps the serve outcome onto exactly two
//! codes — `Ok(())` → `exit-status(0)` (vanilla git exits 0 on
@@ -80,12 +107,16 @@
//! substrate already mapped its own error onto the wire (bands, the
//! denial), and the client's observation is the exit-status above.
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use alkcall::core::auth::Identity;
use alkgit::{Service, SubstrateError};
use russh::Channel;
use russh::server::Handle;
use russh::server::{Handle, Msg};
use russh::{Channel, ChannelMsg, ChannelReadHalf};
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use crate::config::GitSeam;
use crate::dispatch::GitServiceRoute;
@@ -119,12 +150,161 @@ fn exit_code(outcome: &Result<(), SubstrateError>) -> u32 {
}
}
/// The spawned git serving path: dispatch to the seam over the channel
/// duplex, then the exit-status-then-close report through the cloned
/// handle. The channel was taken from the handler's store by the
/// dispatch (the store-and-take handoff); the spawned task owns the
/// stream and its report surface. A failing report send means the
/// session is gone — the lifecycle's `warn`-failed records it (the
/// The seam's duplex over the **split** channel halves — the
/// status-before-close shape's carrier (the module doc's
/// §Status-before-close decision). russh 0.65 keeps its `ChannelRx`/
/// `ChannelTx` types private, so both halves are built from public
/// API: the reader is a [`ChannelHalveReader`] (an owned close-free
/// read half), the writer the write half's `ChannelTx` via
/// `make_writer_ext(None)` — its `poll_shutdown` sends EOF, exactly
/// `into_stream`'s semantics, its `Drop` messages nothing (the
/// `ChannelTx` `Drop` is a writer-notification only). Nothing the
/// seam drops ever messages the wire, so the report's send is the
/// sole close-contracting sender, ordered by construction ahead of
/// the explicit close (sequential awaited sends on the one `Handle`).
struct GitChannelDuplex {
reader: ChannelHalveReader,
writer: Box<dyn AsyncWrite + Unpin + Send>,
}
impl AsyncRead for GitChannelDuplex {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.reader).poll_read(cx, buf)
}
}
impl AsyncWrite for GitChannelDuplex {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
Pin::new(&mut self.writer).poll_write(cx, buf)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.writer).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.writer).poll_shutdown(cx)
}
}
/// The owned close-free read half — the seam-side reader. Semantics
/// repinned from russh's private `ChannelRx<S>` (the `into_stream`
/// shape's reader; identical observable behavior without the
/// `ChannelCloseOnDrop` wrapper): `Data` fills the buffer (partial
/// reads buffered), `ExtendedData` with no sideband and the other
/// non-data messages are swallowed and re-read (the `_` arm),
/// `Eof` ends the stream, and the peer's `Close` leaves the reader
/// to the channel's sender-death EOF (`wait()` → `None` — the
/// channel's internal sender drops at the loop's close processing).
///
/// The `wait()` access is future-owned: each read round-trips the
/// half through a boxed [`Future`] that parks it inside russh's
/// `ChannelReadHalf::wait` and hands it back with the observed
/// message on completion — the reader is fully owned (usable under
/// the seam's `'static` `T` bound) across every park, buffer and
/// drop state; no `Drop` path ever messages the wire.
struct ChannelHalveReader {
slot: Option<ChannelReadHalf>,
parked: Option<PinnedHalfWait>,
buffered: Option<(ChannelMsg, usize)>,
eof: bool,
}
type PinnedHalfWait = Pin<Box<dyn Future<Output = (ChannelReadHalf, Option<ChannelMsg>)> + Send>>;
impl ChannelHalveReader {
fn new(half: ChannelReadHalf) -> Self {
Self {
slot: Some(half),
parked: None,
buffered: None,
eof: false,
}
}
}
impl AsyncRead for ChannelHalveReader {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
loop {
if self.eof {
return Poll::Ready(Ok(()));
}
match self.buffered.take() {
Some((ChannelMsg::Data { data }, index)) => {
let readable = buf.remaining().min(data.len() - index);
buf.put_slice(&data[index..index + readable]);
if index + readable < data.len() {
self.buffered = Some((ChannelMsg::Data { data }, index + readable));
}
return Poll::Ready(Ok(()));
}
Some((ChannelMsg::Eof, _)) => self.eof = true,
Some(_) => continue,
None => {}
}
if self.eof {
return Poll::Ready(Ok(()));
}
if let Some(mut parked) = self.parked.take() {
let (half, observed) = match parked.as_mut().poll(cx) {
Poll::Ready((half, observed)) => (half, observed),
Poll::Pending => {
self.parked = Some(parked);
return Poll::Pending;
}
};
self.slot = Some(half);
match observed {
Some(msg) => self.buffered = Some((msg, 0)),
None => self.eof = true,
}
continue;
}
let Some(mut half) = self.slot.take() else {
self.eof = true;
continue;
};
self.parked = Some(Box::pin(async move {
let observed = half.wait().await;
(half, observed)
}));
}
}
}
fn git_channel_duplex(channel: Channel<Msg>) -> GitChannelDuplex {
let (reader_half, writer_half) = channel.split();
// The `make_writer_ext` return captures the half's borrow (Rust
// 2024 capture rules) though the value only holds clones; the
// trait-object erasure drops the phantom capture, leaving the
// 'static seam bound satisfiable without parking the half.
let writer = Box::new(writer_half.make_writer_ext(None)) as Box<dyn AsyncWrite + Unpin + Send>;
let reader = ChannelHalveReader::new(reader_half);
GitChannelDuplex { reader, writer }
}
/// The spawned git serving path: dispatch to the seam over the
/// [`git_channel_duplex`] (the split halves — the seam contracts
/// nothing on its drops), then the exit-status-then-close report
/// through the cloned handle — the report's send is the sole
/// close-contracting sender and each send awaits before the next is
/// issued, so the status-before-close order holds by construction
/// under any schedule. The channel was taken from the handler's store
/// by the dispatch (the store-and-take handoff); the spawned task owns
/// the halves and their report surface. A failing report send means
/// the session is gone — the lifecycle's `warn`-failed records it (the
/// one warn of the vocabulary).
pub(crate) async fn serve_git_row(
handle: Handle,
@@ -152,7 +332,7 @@ pub(crate) async fn serve_git_row(
&repo_id,
Some(&identity),
alkgit_service(route),
channel.into_stream(),
git_channel_duplex(channel),
seam.limits,
Arc::clone(&seam.registry),
Arc::clone(&seam.refs),