Skip to content

Commit 24d7527

Browse files
coreyleavittEugeny
andauthored
Forward ChannelMsg::Close to channel before dropping sender (#674)
## Problem When the remote side sends `CHANNEL_CLOSE`, the client and server handlers call `self.channels.remove()` which drops the mpsc `Sender`, causing `Channel::wait()` to return `None`. No `ChannelMsg::Close` is ever delivered through the channel. In contrast, the `CHANNEL_EOF` handler correctly sends `ChannelMsg::Eof` through the channel's mpsc before returning. This inconsistency means consumers have no way to distinguish a clean remote close from an unexpected channel teardown. ## RFC context [RFC 4254 Section 5.3](https://www.rfc-editor.org/rfc/rfc4254#section-5.3) specifies: > Upon receiving this message, a party MUST send back an SSH_MSG_CHANNEL_CLOSE unless it has already sent this message for the channel. The channel is considered closed for a party when it has both sent and received SSH_MSG_CHANNEL_CLOSE, and the party may then reuse the channel number. Importantly, the RFC also states: > It is not mandatory to send SSH_MSG_CHANNEL_EOF before SSH_MSG_CHANNEL_CLOSE. This means `CHANNEL_CLOSE` can arrive as the first and only termination signal on a channel -- without a preceding `CHANNEL_EOF`. Without this fix, that close is invisible to consumers: `Channel::wait()` returns `None` with no way to distinguish "remote cleanly closed" from "something dropped the channel handle." The `ChannelMsg::Close` variant exists in the enum but was never delivered through this path. ## Behavioral change This changes the observable behavior of `Channel::wait()`: consumers now receive `Some(ChannelMsg::Close)` before `None` when the remote end sends `CHANNEL_CLOSE`. Existing `while let Some(msg) = ch.wait().await` loops with `_ => {}` catch-alls are **unaffected** -- `Close` is silently ignored, the loop continues, and the next `wait()` returns `None`. However, loops with catch-all panics that don't handle `Close` will now panic where they previously exited cleanly via `None`. Two such patterns in the test suite are hardened in this patch. ## Changes All changes are in the `russh` crate. No public API signatures are changed. ### Core fix (client and server) In both `client/encrypted.rs` and `server/encrypted.rs`, the `CHANNEL_CLOSE` handler now sends `ChannelMsg::Close` through the channel's mpsc sender before removing the channel entry from the map. This matches the existing `CHANNEL_EOF` pattern in both files. ### Documentation Updated doc comments on `Channel::wait()` and `ChannelReadHalf::wait()` to document that `ChannelMsg::Close` is delivered before `None` when the remote end sends `CHANNEL_CLOSE`. ### Tests - **`test_channel_objects`**: Updated to assert `ChannelMsg::Close` is received before `None` on the client side. - **`test_server_receives_close_on_client_close`**: New test validating the server-side fix -- client opens a channel and closes it, server observes `ChannelMsg::Close` before `None`. - **`test_backpressure`**: Added `Some(ChannelMsg::Close) | None => break` to the server handler's match arm. - **`test_data_stream`**: Added `ChannelMsg::Close` alongside `ChannelMsg::Eof` in the break arm. --------- Co-authored-by: Eugene <inbox@null.page>
1 parent bb9cc42 commit 24d7527

5 files changed

Lines changed: 93 additions & 1 deletion

File tree

russh/src/client/encrypted.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,12 @@ impl Session {
394394
// will not be released.
395395
enc.close(channel_num)?;
396396
}
397+
// Forward the close to the channel before removing it, so that
398+
// consumers waiting on `Channel::wait()` receive an explicit
399+
// `ChannelMsg::Close` instead of just seeing `None`.
400+
if let Some(chan) = self.channels.get(&channel_num) {
401+
let _ = chan.send(ChannelMsg::Close).await;
402+
}
397403
self.channels.remove(&channel_num);
398404
client.channel_close(channel_num, self).await
399405
}

russh/src/server/encrypted.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,12 @@ impl Session {
602602
if let Some(ref mut enc) = self.common.encrypted {
603603
enc.channels.remove(&channel_num);
604604
}
605+
// Forward the close to the channel before removing it, so that
606+
// consumers waiting on `Channel::wait()` receive an explicit
607+
// `ChannelMsg::Close` instead of just seeing `None`.
608+
if let Some(chan) = self.channels.get(&channel_num) {
609+
chan.send(ChannelMsg::Close).await.unwrap_or(())
610+
}
605611
self.channels.remove(&channel_num);
606612
debug!("handler.channel_close {channel_num:?}");
607613
handler.channel_close(channel_num, self).await

russh/src/tests.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,13 @@ mod channels {
455455
panic!("Unexpected message {msg:?}");
456456
}
457457

458+
// After the server closes the channel, we should receive an
459+
// explicit Close message before the channel stream ends.
460+
let msg = ch.wait().await.unwrap();
461+
assert!(
462+
matches!(msg, ChannelMsg::Close),
463+
"expected Close, got {msg:?}"
464+
);
458465
assert!(ch.wait().await.is_none());
459466
c
460467
},
@@ -463,6 +470,78 @@ mod channels {
463470
.await;
464471
}
465472

473+
/// Verify that the server-side CHANNEL_CLOSE handler delivers
474+
/// `ChannelMsg::Close` before the channel stream ends.
475+
#[tokio::test]
476+
async fn test_server_receives_close_on_client_close() {
477+
#[derive(Debug)]
478+
struct Client {}
479+
480+
impl client::Handler for Client {
481+
type Error = crate::Error;
482+
483+
async fn check_server_key(
484+
&mut self,
485+
_server_public_key: &crate::keys::ssh_key::PublicKey,
486+
) -> Result<bool, Self::Error> {
487+
Ok(true)
488+
}
489+
}
490+
491+
struct ServerHandle {
492+
channel: Option<tokio::sync::oneshot::Sender<Channel<server::Msg>>>,
493+
}
494+
495+
impl server::Handler for ServerHandle {
496+
type Error = crate::Error;
497+
498+
async fn auth_publickey(
499+
&mut self,
500+
_: &str,
501+
_: &crate::keys::ssh_key::PublicKey,
502+
) -> Result<server::Auth, Self::Error> {
503+
Ok(server::Auth::Accept)
504+
}
505+
506+
async fn channel_open_session(
507+
&mut self,
508+
channel: Channel<server::Msg>,
509+
_session: &mut server::Session,
510+
) -> Result<bool, Self::Error> {
511+
if let Some(tx) = self.channel.take() {
512+
tx.send(channel).unwrap();
513+
}
514+
Ok(true)
515+
}
516+
}
517+
518+
let (tx, rx) = tokio::sync::oneshot::channel::<Channel<server::Msg>>();
519+
let sh = ServerHandle { channel: Some(tx) };
520+
521+
test_session(
522+
Client {},
523+
sh,
524+
|c| async move {
525+
let ch = c.channel_open_session().await.unwrap();
526+
ch.close().await.unwrap();
527+
c
528+
},
529+
|s| async move {
530+
let mut ch = rx.await.unwrap();
531+
// The server should receive an explicit Close message
532+
// when the client closes the channel.
533+
let msg = ch.wait().await.unwrap();
534+
assert!(
535+
matches!(msg, ChannelMsg::Close),
536+
"expected Close, got {msg:?}"
537+
);
538+
assert!(ch.wait().await.is_none());
539+
s
540+
},
541+
)
542+
.await;
543+
}
544+
466545
#[tokio::test]
467546
async fn test_channel_window_size() {
468547
#[derive(Debug)]

russh/tests/test_backpressure.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ impl russh::server::Handler for Server {
135135
while let Ok(_) = rx.changed().await {
136136
match channel.wait().await {
137137
Some(ChannelMsg::Data { .. }) => (),
138+
Some(ChannelMsg::Close) | None => break,
138139
other => panic!("unexpected message {other:?}"),
139140
}
140141
}

russh/tests/test_data_stream.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ impl ChannelDataCopy for ChannelHalves {
7272
match msg {
7373
ChannelMsg::WindowAdjusted { .. } => {}
7474
ChannelMsg::Data { data } => buf.extend(&*data),
75-
ChannelMsg::Eof => break,
75+
ChannelMsg::Eof | ChannelMsg::Close => break,
7676
msg => panic!("Got unexpected message: {msg:?}"),
7777
}
7878
}

0 commit comments

Comments
 (0)