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

+124 -1
View File
@@ -72,11 +72,13 @@
#![allow(dead_code)]
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use alkcall::core::auth::IdentityProvider;
use parking_lot::Mutex;
use russh::client::{AuthResult, Handle, Msg};
use russh::keys::PrivateKeyWithHashAlg;
use russh::{Channel, ChannelMsg, ChannelOpenFailure, Disconnect, Error, MethodSet};
use russh::{Channel, ChannelId, ChannelMsg, ChannelOpenFailure, Disconnect, Error, MethodSet};
use alkssh::{
AdapterError, HostKeyPolicy, Limits, PresentedHostKey, ServeConfig, SshAdapter, fingerprint,
@@ -161,6 +163,70 @@ impl ForwardedAcceptor {
}
}
/// The client-direction data gate (`w4-git-exit-close`'s row
/// vehicle): armed on a channel id, the harness client's packet loop
/// pends inside its `data` hook for that channel's packets (the hook
/// is awaited inline in russh's client loop) — the server's outbound
/// writes for the channel back up through the transport's duplex pair
/// into the server's message queue until [`DataGate::release`]. The
/// held counter (`DataGate::held_packets`) lets the row observe that
/// the freeze is real before it releases.
pub struct DataGate {
target: Mutex<Option<ChannelId>>,
opened: Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
release: Mutex<Option<tokio::sync::oneshot::Receiver<()>>>,
held: AtomicUsize,
}
impl DataGate {
/// Builds the inert gate — arm it with [`DataGate::arm`] once the
/// row has the channel id to gate.
pub fn new() -> Self {
Self {
target: Mutex::new(None),
opened: Mutex::new(None),
release: Mutex::new(None),
held: AtomicUsize::new(0),
}
}
/// Arms the gate on one channel id: the next client-direction
/// data packet for that channel pends the client's packet loop.
pub fn arm(&self, channel: ChannelId) {
let (opened, release) = tokio::sync::oneshot::channel();
*self.target.lock() = Some(channel);
*self.opened.lock() = Some(opened);
*self.release.lock() = Some(release);
}
/// How many data packets have hit the armed gate so far — the
/// row's freeze signal (count `>= 1` means the client's packet
/// loop is pended inside the hook).
pub fn held_packets(&self) -> usize {
self.held.load(Ordering::Acquire)
}
/// Releases the gate — the client's packet loop resumes and the
/// backed-up server queues drain.
pub fn release(&self) {
*self.target.lock() = None;
if let Some(opened) = self.opened.lock().take() {
let _ = opened.send(());
}
}
async fn block(&self, channel: ChannelId) {
if *self.target.lock() != Some(channel) {
return;
}
self.held.fetch_add(1, Ordering::Release);
let release = self.release.lock().take();
if let Some(release) = release {
let _ = release.await;
}
}
}
/// The harness's russh client handler — the [`MemHostKeyPolicy`]
/// decision double bridged into russh's `check_server_key` hook, with
/// the decision input exactly as the wave-4 dial wiring will shape it
@@ -171,6 +237,7 @@ impl ForwardedAcceptor {
pub struct HarnessClientHandler {
policy: Arc<MemHostKeyPolicy>,
acceptor: Option<Arc<ForwardedAcceptor>>,
data_gate: Option<Arc<DataGate>>,
}
impl HarnessClientHandler {
@@ -183,6 +250,7 @@ impl HarnessClientHandler {
Self {
policy,
acceptor: None,
data_gate: None,
}
}
@@ -191,6 +259,17 @@ impl HarnessClientHandler {
Self {
policy: self.policy,
acceptor: Some(acceptor),
data_gate: self.data_gate,
}
}
/// Installs the client-direction data gate (`w4-git-exit-close`'s
/// row vehicle). Inert until the gate is armed on a channel id.
pub fn with_data_gate(self, gate: Arc<DataGate>) -> Self {
Self {
policy: self.policy,
acceptor: self.acceptor,
data_gate: Some(gate),
}
}
}
@@ -198,6 +277,24 @@ impl HarnessClientHandler {
impl russh::client::Handler for HarnessClientHandler {
type Error = Error;
/// The data gate's hook: when the gate is armed on this channel id
/// and not yet released, the client's packet loop pends inside it
/// (the hook is awaited inline) — the server's outbound writes
/// back up through the transport into its message queue until the
/// row releases. Inert otherwise; also the block counter the row
/// polls to know the freeze is real.
async fn data(
&mut self,
channel: ChannelId,
_data: &[u8],
_session: &mut russh::client::Session,
) -> Result<(), Self::Error> {
if let Some(gate) = self.data_gate.as_ref() {
gate.block(channel).await;
}
Ok(())
}
/// The policy decision, verbatim: the presented key's fingerprint
/// is computed on the crate's single fingerprint path, the
/// endpoint labels are the loopback constants, and the policy's
@@ -358,6 +455,29 @@ pub async fn drive_with_acceptor(
host_key_policy: Arc<MemHostKeyPolicy>,
limits: Limits,
acceptor: Option<Arc<ForwardedAcceptor>>,
) -> Result<LoopbackSession, Error> {
mount_and_connect(adapter, host_key_policy, limits, acceptor, None).await
}
/// [`drive`] with the client-direction data gate installed — the
/// `w4-git-exit-close` row's vehicle.
pub async fn drive_with_data_gate(
adapter: SshAdapter,
host_key_policy: Arc<MemHostKeyPolicy>,
limits: Limits,
gate: Option<Arc<DataGate>>,
) -> Result<LoopbackSession, Error> {
mount_and_connect(adapter, host_key_policy, limits, None, gate).await
}
/// `[drive*]`'s shared core: mount + connect (the doc decisions at
/// [`drive`] cover both; the optional hooks ride the handler).
async fn mount_and_connect(
adapter: SshAdapter,
host_key_policy: Arc<MemHostKeyPolicy>,
limits: Limits,
acceptor: Option<Arc<ForwardedAcceptor>>,
data_gate: Option<Arc<DataGate>>,
) -> Result<LoopbackSession, Error> {
let (client_io, server_io) = tokio::io::duplex(DUPLEX_CAPACITY);
let server_task = tokio::spawn(async move { adapter.handle(server_io).await });
@@ -366,6 +486,9 @@ pub async fn drive_with_acceptor(
if let Some(acceptor) = acceptor {
handler = handler.with_forwarded_acceptor(acceptor);
}
if let Some(gate) = data_gate {
handler = handler.with_data_gate(gate);
}
let client = russh::client::connect_stream(client_config(limits), client_io, handler).await?;
Ok(LoopbackSession {