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}