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
+124
-1
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user