8000 fix: Apply SSH window-change requests so the session pty resizes mid-session by gominimal-aw-bot[bot] · Pull Request #968 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions crates/minimald/src/connection.rs
8000
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,34 @@ impl russh::server::Handler for ConnectionHandler {
Ok(())
}

/// SSH `window-change`: a terminal resize that arrives before the shell has
/// attached. Update the pending pty size so the session opens at the latest
/// dimensions. Once the shell attaches the channel is taken and further
/// resizes arrive as `ChannelMsg::WindowChange` in the session-host binding
/// loop instead, so a `None` pending config here is expected and ignored.
/// `window-change` never sets `want_reply`, so no channel reply is sent.
async fn window_change_request(
&mut self,
id: ChannelId,
col_width: u32,
row_height: u32,
pix_width: u32,
pix_height: u32,
_session: &mut Session,
) -> Result<(), Self::Error> {
protocol_trace!(
"Got window_change_request on channel {id}: sz=({col_width}, {row_height}) p_sz=({pix_width}, {pix_height})",
);

if let Some(p) = self.0.lock().await.pending_config_mut(id)
&& let Some(pty) = p.pty.as_mut()
{
pty.char_sizes = (col_width, row_height);
pty.pixel_sizes = (pix_width, pix_height);
}
Ok(())
}

async fn exec_request(
&mut self,
id: ChannelId,
Expand Down
83 changes: 83 additions & 0 deletions crates/minimald/src/session.rs
8000
Original file line number Diff line number Diff line change
Expand Up @@ -1780,6 +1780,89 @@ mod tests {
);
}

/// Asks the mock program to print its terminal size (`stty size`).
async fn query_size(channel: &russh::Channel<russh::client::Msg>) {
channel
.data_bytes(format!("{}\n", crate::session_host::MOCK_SIZE_LINE).into_bytes())
.await
.unwrap();
}

/// Reads channel output until a complete `stty size` line ("rows cols")
/// arrives, returning `(rows, cols)`. Panics on timeout or early close.
async fn read_size(channel: &mut russh::Channel<russh::client::Msg>) -> (u16, u16) {
let mut buf = String::new();
loop {
match tokio::time::timeout(Duration::from_secs(10), channel.wait()).await {
Ok(Some(ChannelMsg::Data { data })) => {
buf.push_str(&String::from_utf8_lossy(&data));
// Only scan complete lines (drop the trailing, possibly
// partial segment) so a chunk split mid-number can't parse
// as a spurious size.
let mut lines: Vec<&str> = buf.split('\n').collect();
lines.pop();
for line in lines {
let mut it = line.trim_end_matches('\r').split_whitespace();
if let (Some(rows), Some(cols), None) = (it.next(), it.next(), it.next())
&& let (Ok(rows), Ok(cols)) = (rows.parse::<u16>(), cols.parse::<u16>())
{
return (rows, cols);
}
}
}
Ok(Some(_)) => {}
Ok(None) => panic!("channel closed before a size line arrived; got: {buf:?}"),
Err(_) => panic!("timed out waiting for a size line; got: {buf:?}"),
}
}
}

/// A mid-session terminal resize (SSH `window-change`) must reach the
/// running pty: the session host has to translate `ChannelMsg::WindowChange`
/// into a `TIOCSWINSZ` on the master so `stty size` inside the session
/// reports the new dimensions. Regression test for the binding loop dropping
/// every non-`RequestPty` channel message into a silent `debug` arm.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn window_change_after_attach_resizes_the_pty() {
let server = TestServer::new().await;
let mut client = server.connect().await;
let session_id = create_session(&mut client).await;

// `open_shell` negotiates an 80-col x 24-row pty, then attaches.
let mut channel = client.open_shell(session_id).await;

// The pty starts at the negotiated size (stty prints "rows cols").
query_size(&channel).await;
assert_eq!(read_size(&mut channel).await, (24, 80));

// Resize mid-session to 120 cols x 40 rows.
channel.window_change(120, 40, 0, 0).await.unwrap();

// The pty must now report the new size; before the fix the resize was
// dropped and this stayed (24, 80).
query_size(&channel).await;
assert_eq!(read_size(&mut channel).await, (40, 120));
}

/// A `window-change` that arrives *before* the shell attaches must update
/// the initial pty size, so the session opens at the latest dimensions.
/// Regression test for the connection handler having no `window_change_request`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn window_change_before_shell_sets_initial_pty_size() {
let server = TestServer::new().await;
let mut client = server.connect().await;
let session_id = create_session(&mut client).await;

// Negotiate 80x24, then resize to 120x40 before the shell attaches.
let mut channel = client
.open_shell_resized(session_id, (80, 24), (120, 40))
.await;

// The pty must open at the resized dimensions, not the pty-req ones.
query_size(&channel).await;
assert_eq!(read_size(&mut channel).await, (40, 120));
}

/// Selecting "delete" on the shell-exit prompt must tear the connection down
/// *and* destroy the session (record removed), routed through the manager
/// via the binding's weak handle.
Expand Down
28 changes: 27 additions & 1 deletion crates/minimald/src/session_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,26 @@ impl Binding {
modes: terminal_modes.to_vec(),
})).await;
},
// A mid-session terminal resize. Forward the new
// dimensions down the same channel as the initial
// pty-req so the consumer's `set_size` runs
// `TIOCSWINSZ` on the master and re-sizes the vt100
// parser; the kernel then delivers SIGWINCH to the
// foreground process group. `window-change` carries
// no term/modes, so leave those empty.
russh::ChannelMsg::WindowChange {
col_width,
row_height,
pix_width,
pix_height,
} => {
let _ = self.stdin_tx.send(Either::Right(RequestedPty {
char_sizes: (col_width, row_height),
pixel_sizes: (pix_width, pix_height),
term: String::new(),
modes: Vec::new(),
})).await;
}
// Flow-control window updates fire on every
// burst of bytes forwarded through the
// channel, v. noisy.
Expand Down Expand Up @@ -1203,6 +1223,12 @@ impl SessionProcess for MockProcess {
#[cfg(test)]
pub(crate) const MOCK_EXIT_LINE: &str = "quit";

/// The sentinel stdin line that makes [`MockLauncher`]'s program print its
/// current terminal size (`stty size` → "rows cols") back over the channel, so
/// a test can observe a `window-change`-driven `TIOCSWINSZ` land on the pty.
#[cfg(test)]
pub(crate) const MOCK_SIZE_LINE: &str = "size";

/// A test [`SessionLauncher`] that wires a plain, un-sandboxed host process to a
/// freshly opened PTY — so the [`Host`] runtime can be exercised end-to-end
/// without building a real sandbox (which needs packages unavailable in the
Expand Down Expand Up @@ -1230,7 +1256,7 @@ impl SessionLauncher for MockLauncher {
let pty = Pty::open(sz)?;

let script = format!(
r#"while read line; do [ "$line" = {MOCK_EXIT_LINE} ] && exit 0; printf 'got:%s\n' "$line"; done"#
r#"while read line; do [ "$line" = {MOCK_EXIT_LINE} ] && exit 0; [ "$line" = {MOCK_SIZE_LINE} ] && stty size && continue; printf 'got:%s\n' "$line"; done"#
);
let mut command = std::process::Command::new("/bin/sh");
command.arg("-c").arg(&script);
Expand Down
30 changes: 30 additions & 0 deletions crates/minimald/src/test_harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,36 @@ impl TestClient {
channel
}

/// Like [`Self::open_shell`], but negotiates the pty at `initial`
/// `(col_width, row_height)`, issues an SSH `window-change` to `resized`
/// `(col_width, row_height)` *before* the shell request, then attaches.
///
/// Exercises the pre-attach resize path: the resize has to update the
/// pending pty size so the session opens at `resized` rather than the
/// dimensions from the original `pty-req`.
pub async fn open_shell_resized(
&mut self,
session_id: SessionId,
initial: (u32, u32),
resized: (u32, u32),
) -> russh::Channel<russh::client::Msg> {
let channel = self.handle.channel_open_session().await.unwrap();
channel
.set_env(true, crate::MINIMAL_SESSION_ID_ENV, session_id.to_string())
.await
.unwrap();
channel
.request_pty(true, "xterm", initial.0, initial.1, 0, 0, &[])
.await
.unwrap();
channel
.window_change(resized.0, resized.1, 0, 0)
.await
.unwrap();
channel.request_shell(true).await.unwrap();
channel
}

/// Opens a fresh session channel, applies `env` and optionally a PTY,
/// fires an `exec` request for `command`, writes `stdin`, then drains
/// the channel to completion.
Expand Down
0