Skip to main content

gunnar_ssh/
channel.rs

1//! An **owned** byte stream over a russh channel that does *not* close the
2//! channel when it is dropped.
3//!
4//! # The bug this module exists to make impossible
5//!
6//! russh's [`Channel::into_stream`](russh::Channel::into_stream) wraps the
7//! channel in `ChannelCloseOnDrop`, whose `Drop` does
8//!
9//! ```text
10//! tokio::spawn(async move { sender.send(ChannelMsg::Close).await })
11//! ```
12//!
13//! (`russh-0.61.2/src/channels/io/mod.rs`). The server hands that stream to the
14//! transport, so the `Close` is queued **from a task nobody awaits** the moment
15//! the transfer finishes, racing the `exit-status` the server sends next.
16//! OpenSSH frees a channel on `Close` and discards anything that arrives after
17//! it, so the race decided whether a client saw an exit status at all.
18//!
19//! Measured 2026-08-03, before this module existed: a raw
20//! `ssh … git-upload-pack 'team/project.git'` returned a complete 244-byte
21//! advertisement and exit status **255**, and `git push` printed
22//! `* [new branch] HEAD -> main` followed by `error: failed to push some refs`
23//! for a push that had fully succeeded server-side. `gunnar-cli` carried a
24//! 250 ms `linger` timer to invert the race; it is deleted.
25//!
26//! # The fix, by construction (one implementation)
27//!
28//! [`Channel::split`](russh::Channel::split) hands back a
29//! [`ChannelReadHalf`] and a [`ChannelWriteHalf`], **neither of which closes the
30//! channel on drop**. The write half stays with the server, which sends
31//! `exit-status` and then `close` through *the same sender the data went
32//! through*, so the ordering is a property of one FIFO queue rather than of a
33//! sleep. The read half is moved in here and turned into an owned
34//! [`AsyncRead`], because russh's own `make_reader` borrows the half and the
35//! transport needs a `'static` stream (it runs the blocking git protocol on
36//! `spawn_blocking`, which will not accept a borrow).
37//!
38//! There is no timing dependency left anywhere on this path.
39
40use std::future::Future;
41use std::io;
42use std::pin::Pin;
43use std::task::{Context, Poll};
44
45use russh::{ChannelMsg, ChannelReadHalf};
46use tokio::io::{AsyncRead, ReadBuf};
47
48/// The future that owns the read half while it waits for the next message.
49type Pending = Pin<Box<dyn Future<Output = (ChannelReadHalf, Option<ChannelMsg>)> + Send>>;
50
51/// Where the reader is between two `poll_read` calls.
52enum State {
53    /// Nothing is in flight; the half is parked here.
54    Idle(ChannelReadHalf),
55    /// A `wait()` is in flight, and it owns the half until it resolves.
56    Waiting(Pending),
57    /// The peer sent EOF (or the channel went away). Every further read is EOF.
58    Ended,
59}
60
61/// The data band of an SSH channel, as an owned [`AsyncRead`].
62///
63/// Dropping this drops a [`ChannelReadHalf`], which sends nothing at all. That
64/// is the whole point. See the module docs.
65pub struct ChannelReader {
66    state: State,
67    /// A `ChannelMsg::Data` that did not fit in the last `poll_read`, and how
68    /// much of it has already been handed out.
69    partial: Option<(ChannelMsg, usize)>,
70}
71
72impl ChannelReader {
73    /// Take ownership of a channel's read half.
74    pub fn new(half: ChannelReadHalf) -> Self {
75        ChannelReader {
76            state: State::Idle(half),
77            partial: None,
78        }
79    }
80}
81
82impl std::fmt::Debug for ChannelReader {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.debug_struct("ChannelReader").finish_non_exhaustive()
85    }
86}
87
88/// The bytes of a `ChannelMsg::Data`, and nothing for anything else.
89///
90/// [`next_data`] only ever parks a `Data`, so the fallback arm is unreachable
91/// in practice; it is a `&[]` rather than a panic because `ChannelMsg` is
92/// `#[non_exhaustive]` and a future russh could add a variant here.
93fn payload(message: &ChannelMsg) -> &[u8] {
94    match message {
95        ChannelMsg::Data { data } => data,
96        _ => &[],
97    }
98}
99
100/// Wait for the next `Data` message, handing the half back with it.
101///
102/// Anything that is not `Data` is skipped **inside** the future rather than by
103/// returning `Poll::Pending` after a self-wake, so a chatty peer cannot spin
104/// the reactor. `Eof`, `Close` and a dead channel all mean end-of-stream.
105fn next_data(mut half: ChannelReadHalf) -> Pending {
106    Box::pin(async move {
107        loop {
108            match half.wait().await {
109                Some(ChannelMsg::Data { data }) => {
110                    return (half, Some(ChannelMsg::Data { data }));
111                }
112                Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => return (half, None),
113                Some(_) => continue,
114            }
115        }
116    })
117}
118
119impl AsyncRead for ChannelReader {
120    fn poll_read(
121        self: Pin<&mut Self>,
122        cx: &mut Context<'_>,
123        buf: &mut ReadBuf<'_>,
124    ) -> Poll<io::Result<()>> {
125        let me = self.get_mut();
126        loop {
127            if let Some((message, taken)) = me.partial.as_mut() {
128                let data = payload(message);
129                // Clamped by `min`, so neither index can be out of range.
130                let readable = buf.remaining().min(data.len().saturating_sub(*taken));
131                buf.put_slice(&data[*taken..*taken + readable]);
132                *taken += readable;
133                if *taken >= data.len() {
134                    me.partial = None;
135                }
136                return Poll::Ready(Ok(()));
137            }
138
139            match std::mem::replace(&mut me.state, State::Ended) {
140                // Reading past EOF is not an error, it is EOF again: a zero-fill
141                // return is exactly how `AsyncRead` says so.
142                State::Ended => return Poll::Ready(Ok(())),
143                State::Idle(half) => me.state = State::Waiting(next_data(half)),
144                State::Waiting(mut waiting) => match waiting.as_mut().poll(cx) {
145                    Poll::Pending => {
146                        me.state = State::Waiting(waiting);
147                        return Poll::Pending;
148                    }
149                    Poll::Ready((half, Some(message))) => {
150                        me.state = State::Idle(half);
151                        me.partial = Some((message, 0));
152                    }
153                    Poll::Ready((_, None)) => {
154                        me.state = State::Ended;
155                        return Poll::Ready(Ok(()));
156                    }
157                },
158            }
159        }
160    }
161}
162
163// ── the writer that hears the session end ───────────────────────────────────
164
165/// **The session is over**, as a value every transfer on it can watch.
166///
167/// The [`crate::server`] connection handler owns the sender and sets it from
168/// `Drop` — and russh drops the handler on EVERY way a session ends: the client
169/// hung up, its keepalives went unanswered, the inactivity bound, a protocol
170/// error, the server's own shutdown.
171pub type SessionOver = tokio::sync::watch::Receiver<bool>;
172
173/// Resolve once `over` says the session has ended (or its sender is gone,
174/// which is the same thing said by `Drop`).
175pub async fn session_over(mut over: SessionOver) {
176    let _ = over.wait_for(|ended| *ended).await;
177}
178
179/// The error a transfer gets when the client's session ended under it.
180pub fn session_ended_error() -> io::Error {
181    io::Error::new(
182        io::ErrorKind::ConnectionAborted,
183        "the client's ssh session ended mid-transfer (it hung up, or its link went silent \
184         and its keepalives went unanswered)",
185    )
186}
187
188/// How long a transfer whose session has ended is given to end by itself.
189///
190/// The session's end also ends the channel, so a transfer that was only
191/// waiting for the client's next request reads EOF at once and ends cleanly;
192/// one still working (main building a pack) is cut when this runs out.
193pub const SESSION_END_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
194
195/// The grace when the transfer's fan has others queued behind it: a worker a
196/// hung-up client holds is a worker a waiting client does not get.
197pub const CROWDED_SESSION_END_GRACE: std::time::Duration = std::time::Duration::from_millis(200);
198
199/// **The grace a transfer whose session ended is given**: `calm`, or
200/// `crowded` when `queued` operations wait for its fan.
201pub fn grace_for(
202    calm: std::time::Duration,
203    crowded: std::time::Duration,
204    queued: usize,
205) -> std::time::Duration {
206    if queued > 0 {
207        crowded.min(calm)
208    } else {
209        calm
210    }
211}
212
213/// **The word a transfer's git work hears when its client is gone.**
214///
215/// Set by [`serve_until_session_ends`] when the grace runs out, and by a
216/// transport whose serve future is dropped. The git work checks it at its safe
217/// points — every read from and write to the client, and before it opens the
218/// store — and ends there. It is never checked between the ref updates of a
219/// receive-pack transaction: those land together or not at all.
220///
221/// Cheap to clone; every clone is the same flag.
222#[derive(Clone, Default)]
223pub struct Cancel(std::sync::Arc<CancelInner>);
224
225#[derive(Default)]
226struct CancelInner {
227    set: std::sync::atomic::AtomicBool,
228    wake: tokio::sync::Notify,
229}
230
231impl Cancel {
232    /// A flag nobody has set.
233    pub fn new() -> Self {
234        Self::default()
235    }
236
237    /// Tell the transfer to stop at its next safe point.
238    pub fn cancel(&self) {
239        self.0.set.store(true, std::sync::atomic::Ordering::SeqCst);
240        self.0.wake.notify_waiters();
241    }
242
243    /// Has the transfer been told to stop?
244    pub fn is_cancelled(&self) -> bool {
245        self.0.set.load(std::sync::atomic::Ordering::SeqCst)
246    }
247
248    /// Resolves once the transfer has been told to stop.
249    pub async fn cancelled(&self) {
250        loop {
251            let notified = self.0.wake.notified();
252            if self.is_cancelled() {
253                return;
254            }
255            notified.await;
256        }
257    }
258}
259
260impl std::fmt::Debug for Cancel {
261    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262        f.debug_tuple("Cancel").field(&self.is_cancelled()).finish()
263    }
264}
265
266/// **A transfer, bound to its session**: what `serve` returns, unless the
267/// session ends first and the transfer does not end by itself within
268/// `grace()` (asked when the session ends; [`grace_for`] — the server gives
269/// [`SESSION_END_GRACE`], or [`CROWDED_SESSION_END_GRACE`] when others wait
270/// for the fan), in which case it ends with `ended()`.
271///
272/// The server's transfer task runs every `git-upload-pack` /
273/// `git-receive-pack` through this, so a client that went silent while main
274/// still builds its pack gives back its fan worker, admission slot and store
275/// handle when its session ends (the UpCloud mockup's LD1/LD2, 2026-10-09).
276///
277/// The grace is what keeps a SERVED client honest: `ls-remote` reads the ref
278/// list and hangs up, and its session's end used to win the race against
279/// upload-pack's own clean EOF, so every such ls-remote was logged as a failed
280/// transfer (test2 install receipt 2026-10-09: WARN spam, REFUSED
281/// [log-verdict]). The protocol decides whether the client was served, not
282/// which of the two wakes first.
283///
284/// # The cut waits for the work (stinky row 1, 2026-10-10)
285///
286/// When the grace runs out, `cancel` is set and `serve` is **awaited to its
287/// end**, not dropped. Dropping it dropped only the `JoinHandle` of the
288/// transport's blocking task: the git work went on (a pack being built, refs
289/// being moved) while the fan worker running this was given back, so the fan
290/// bounded nothing. The work ends at its next safe point once it reads the
291/// flag ([`Cancel`]); the worker is held until it has.
292pub async fn serve_until_session_ends<T, E>(
293    serve: impl Future<Output = Result<T, E>>,
294    over: SessionOver,
295    cancel: &Cancel,
296    grace: impl FnOnce() -> std::time::Duration,
297    ended: impl FnOnce() -> E,
298) -> Result<T, E> {
299    let mut serve = std::pin::pin!(serve);
300    tokio::select! {
301        served = &mut serve => served,
302        () = session_over(over) => match tokio::time::timeout(grace(), &mut serve).await {
303            Ok(served) => served,
304            Err(_) => {
305                cancel.cancel();
306                tracing::debug!(target: "gunnar::ssh", event = "ssh.transfer.told_to_stop", "the session ended and the transfer did not end within the grace; it is told to stop and its worker waits for it");
307                match serve.await {
308                    Ok(served) => Ok(served),
309                    Err(_) => Err(ended()),
310                }
311            }
312        },
313    }
314}
315
316/// A channel writer that **ends with an error when the session does**.
317///
318/// # The bug this exists to make impossible (cov-13 finding 4, 2026-10-06)
319///
320/// russh's channel writer (`ChannelTx`, what `make_writer` returns) waits for
321/// SSH window on a `Notify` that only a window-adjust from the client fires. A
322/// session that ENDS fires nothing. So a clone or fetch streaming a pack to a
323/// client whose link died — the window shut, the session then ended by the
324/// keepalive — left the transfer parked forever: its fan worker, its admission
325/// slot, its memory-ledger charge, its store handle, all held for good. One
326/// dead client per leak, until the fan refuses everyone.
327///
328/// Every poll here also polls the session's end, so the parked write wakes and
329/// fails with [`session_ended_error`] the moment the session is gone.
330pub struct SessionBound<W> {
331    inner: W,
332    over: SessionOver,
333    /// The pending "session over" wait, kept across polls so its waker stays
334    /// registered while the inner write is parked.
335    ending: Option<Pin<Box<dyn Future<Output = ()> + Send>>>,
336}
337
338impl<W> SessionBound<W> {
339    /// Bind `inner` to the session `over` watches.
340    pub fn new(inner: W, over: SessionOver) -> Self {
341        SessionBound {
342            inner,
343            over,
344            ending: None,
345        }
346    }
347
348    /// `Ready` once the session has ended; registers the waker otherwise.
349    fn poll_over(&mut self, cx: &mut Context<'_>) -> Poll<()> {
350        if *self.over.borrow() {
351            return Poll::Ready(());
352        }
353        let over = self.over.clone();
354        let ending = self
355            .ending
356            .get_or_insert_with(|| Box::pin(session_over(over)));
357        ending.as_mut().poll(cx)
358    }
359}
360
361impl<W> std::fmt::Debug for SessionBound<W> {
362    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363        f.debug_struct("SessionBound").finish_non_exhaustive()
364    }
365}
366
367impl<W: tokio::io::AsyncWrite + Unpin> tokio::io::AsyncWrite for SessionBound<W> {
368    fn poll_write(
369        self: Pin<&mut Self>,
370        cx: &mut Context<'_>,
371        buf: &[u8],
372    ) -> Poll<io::Result<usize>> {
373        let me = self.get_mut();
374        match Pin::new(&mut me.inner).poll_write(cx, buf) {
375            Poll::Ready(r) => Poll::Ready(r),
376            Poll::Pending => match me.poll_over(cx) {
377                Poll::Ready(()) => Poll::Ready(Err(session_ended_error())),
378                Poll::Pending => Poll::Pending,
379            },
380        }
381    }
382
383    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
384        let me = self.get_mut();
385        match Pin::new(&mut me.inner).poll_flush(cx) {
386            Poll::Ready(r) => Poll::Ready(r),
387            Poll::Pending => match me.poll_over(cx) {
388                Poll::Ready(()) => Poll::Ready(Err(session_ended_error())),
389                Poll::Pending => Poll::Pending,
390            },
391        }
392    }
393
394    fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
395        let me = self.get_mut();
396        match Pin::new(&mut me.inner).poll_shutdown(cx) {
397            Poll::Ready(r) => Poll::Ready(r),
398            Poll::Pending => match me.poll_over(cx) {
399                Poll::Ready(()) => Poll::Ready(Err(session_ended_error())),
400                Poll::Pending => Poll::Pending,
401            },
402        }
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use std::time::Duration;
410
411    fn ended_session() -> (tokio::sync::watch::Sender<bool>, SessionOver) {
412        let (tx, rx) = tokio::sync::watch::channel(false);
413        tx.send_replace(true);
414        (tx, rx)
415    }
416
417    /// ★ **A client that hung up after it was served is no failed transfer**
418    /// (test2 install receipt 2026-10-09 18:35 UTC, REFUSED [log-verdict]):
419    /// `ls-remote` reads the ref list and hangs up. Its session ends while
420    /// upload-pack waits for a next command that never comes, and upload-pack
421    /// ends cleanly on that EOF a moment later. Every such ls-remote was logged
422    /// as `git.transfer.failed` + `git.exit_status.undelivered` at WARN, and the
423    /// kit's functest (7 repositories listed in 38 s) tripped the WARN-spam rule.
424    #[tokio::test]
425    async fn a_client_that_hung_up_after_being_served_is_no_failed_transfer() {
426        let (_tx, over) = ended_session();
427        let serve = async {
428            // upload-pack reading the EOF the ended session leaves behind
429            tokio::time::sleep(Duration::from_millis(50)).await;
430            Ok::<(), io::Error>(())
431        };
432        let served = serve_until_session_ends(
433            serve,
434            over,
435            &Cancel::new(),
436            || SESSION_END_GRACE,
437            session_ended_error,
438        )
439        .await;
440        assert!(
441            served.is_ok(),
442            "a transfer that ended cleanly was called failed: {served:?}"
443        );
444    }
445
446    /// Stinky row 19 (2026-10-10): the 2 s grace was a constant, so every
447    /// client that hung up held a fan worker up to 2 s more — however many
448    /// waited behind it, which is what a connect-and-hang-up flood feeds on.
449    /// The grace is the caller's, and shorter when the fan is crowded.
450    #[test]
451    fn the_grace_is_shorter_when_others_wait_for_the_fan() {
452        let (calm, crowded) = (Duration::from_secs(2), Duration::from_millis(200));
453        assert_eq!(
454            grace_for(calm, crowded, 0),
455            calm,
456            "nobody waits: the whole grace"
457        );
458        assert_eq!(
459            grace_for(calm, crowded, 1),
460            crowded,
461            "one waits: the crowded grace"
462        );
463        assert_eq!(
464            grace_for(Duration::from_millis(100), crowded, 5),
465            Duration::from_millis(100),
466            "never longer than the calm grace"
467        );
468    }
469
470    #[tokio::test]
471    async fn a_transfer_is_cut_after_the_grace_it_is_given() {
472        let (_tx, over) = ended_session();
473        let cancel = Cancel::new();
474        let heard = cancel.clone();
475        let serve = async move {
476            heard.cancelled().await;
477            Err::<(), io::Error>(io::Error::new(
478                io::ErrorKind::ConnectionAborted,
479                "told to stop",
480            ))
481        };
482        let started = std::time::Instant::now();
483        let served = serve_until_session_ends(
484            serve,
485            over,
486            &cancel,
487            || Duration::from_millis(50),
488            session_ended_error,
489        )
490        .await;
491        assert!(served.is_err());
492        assert!(
493            started.elapsed() < Duration::from_secs(1),
494            "cut after {:?}, not the 50 ms it was given",
495            started.elapsed()
496        );
497    }
498
499    /// The bug the binding exists for stays fixed: a transfer that is still
500    /// working (main building the pack) when the session ends is told to stop,
501    /// and gives back what it holds, within a bound, once it has.
502    #[tokio::test]
503    async fn a_transfer_still_working_when_the_session_ends_is_cut() {
504        let (_tx, over) = ended_session();
505        let cancel = Cancel::new();
506        let heard = cancel.clone();
507        let serve = async move {
508            heard.cancelled().await;
509            Err::<(), io::Error>(io::Error::new(
510                io::ErrorKind::ConnectionAborted,
511                "told to stop",
512            ))
513        };
514        let served = tokio::time::timeout(
515            Duration::from_secs(10),
516            serve_until_session_ends(
517                serve,
518                over,
519                &cancel,
520                || SESSION_END_GRACE,
521                session_ended_error,
522            ),
523        )
524        .await
525        .expect("a transfer outlived its session by 10 s");
526        let err =
527            served.expect_err("a transfer still working when its session ended was called served");
528        assert_eq!(err.kind(), io::ErrorKind::ConnectionAborted, "{err}");
529    }
530
531    /// ★ **A hung-up client cannot make blocking work exceed the fan** (stinky
532    /// row 1, 2026-10-10). The transport's git work runs on tokio's blocking
533    /// pool, and when the grace ran out only its `JoinHandle` was dropped: the
534    /// fan worker was released while the work (a pack being built, refs being
535    /// moved) went on, so the fan bounded nothing and a stream of clients that
536    /// hung up mid-clone could put up to 512 blocking threads to work. Here a
537    /// fan of ONE worker serves two transfers whose clients hung up while main
538    /// still works; at no moment may two pieces of that work run together.
539    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
540    async fn a_hung_up_client_cannot_make_blocking_work_exceed_the_fan() {
541        use crate::governor::OpClass;
542        use crate::pipeline::{Job, Pipeline};
543        use std::sync::atomic::{AtomicUsize, Ordering};
544        use std::sync::Arc;
545
546        // Longer than the grace, so the work is still running when it runs out.
547        const WORK: Duration = Duration::from_secs(3);
548        let fan: Pipeline<Job> = Pipeline::start(OpClass::Clone, 1, 4, |job: Job| job());
549        let live = Arc::new(AtomicUsize::new(0));
550        let peak = Arc::new(AtomicUsize::new(0));
551        let (done_tx, mut done_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
552        for _ in 0..2 {
553            let (live, peak, done) = (Arc::clone(&live), Arc::clone(&peak), done_tx.clone());
554            let handle = tokio::runtime::Handle::current();
555            let (session, over) = ended_session();
556            fan.submit(Box::new(move || {
557                handle.block_on(async move {
558                    let _session = session;
559                    let serve = async move {
560                        tokio::task::spawn_blocking(move || {
561                            let now = live.fetch_add(1, Ordering::SeqCst) + 1;
562                            peak.fetch_max(now, Ordering::SeqCst);
563                            let until = std::time::Instant::now() + WORK;
564                            while std::time::Instant::now() < until {
565                                std::thread::sleep(Duration::from_millis(10));
566                            }
567                            live.fetch_sub(1, Ordering::SeqCst);
568                            Ok::<(), io::Error>(())
569                        })
570                        .await
571                        .map_err(io::Error::other)?
572                    };
573                    let _ = serve_until_session_ends(
574                        serve,
575                        over,
576                        &Cancel::new(),
577                        || SESSION_END_GRACE,
578                        session_ended_error,
579                    )
580                    .await;
581                });
582                let _ = done.send(());
583            }))
584            .expect("the fan takes the transfer");
585        }
586        for _ in 0..2 {
587            done_rx.recv().await.expect("a transfer finished");
588        }
589        // Whatever the fan let go of early is still working; let it end so the
590        // peak is the whole story.
591        while live.load(Ordering::SeqCst) > 0 {
592            tokio::time::sleep(Duration::from_millis(10)).await;
593        }
594        let peak = peak.load(Ordering::SeqCst);
595        assert!(
596            peak <= 1,
597            "a fan of one worker had {peak} transfers' blocking work running at once: the worker \
598             was released while the work of a client that hung up went on"
599        );
600    }
601
602    /// A transfer that ends while its session is alive is what it says.
603    #[tokio::test]
604    async fn a_transfer_with_its_session_alive_is_its_own_verdict() {
605        let (_tx, over) = tokio::sync::watch::channel(false);
606        let ok = serve_until_session_ends(
607            async { Ok::<u8, io::Error>(7) },
608            over.clone(),
609            &Cancel::new(),
610            || SESSION_END_GRACE,
611            session_ended_error,
612        )
613        .await;
614        assert_eq!(ok.unwrap(), 7);
615        let err = serve_until_session_ends(
616            async { Err::<u8, io::Error>(io::Error::other("store")) },
617            over,
618            &Cancel::new(),
619            || SESSION_END_GRACE,
620            session_ended_error,
621        )
622        .await;
623        assert_eq!(err.unwrap_err().to_string(), "store");
624    }
625}