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:
1 parent
dba64d65d3
commit
9cf5bfbaf3
5 files changed
+614
-15
No files matched your search
+189
-9
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user