Skip to content

fix(pty): re-arm mio readiness after raw socket WouldBlock on Windows - #79

Open
srstack wants to merge 1 commit into
otty-shell:mainfrom
srstack:fix-ssh-windows-mio-rearm
Open

fix(pty): re-arm mio readiness after raw socket WouldBlock on Windows#79
srstack wants to merge 1 commit into
otty-shell:mainfrom
srstack:fix-ssh-windows-mio-rearm

Conversation

@srstack

@srstack srstack commented Jul 31, 2026

Copy link
Copy Markdown

Problem

On Windows, an SSH session displays the initial login banner/motd and then goes permanently deaf: the shell prompt never appears, and no further output arrives, while keyboard input keeps being sent successfully.

Root cause

mio's Windows backend is edge-triggered by design (src/sys/windows/selector.rs):

// In mio, we have to simulate Edge-triggered behavior to match API usage.
// The strategy here is to intercept all read/write from user that could
// cause WouldBlock usage, then reregister the socket to reset the interests.
self.user_evts &= !afd_events;

After delivering an event, mio clears the socket's interest bits. The interest is only re-armed when a read/write through mio::net::TcpStream hits WouldBlock (see IoSourceState::do_io, which re-registers on WouldBlock).

But all SSH channel I/O in otty-pty bypasses mio: libssh2 reads and writes the raw socket directly. So mio never observes a WouldBlock, the POLL_RECEIVE interest is lost after the first event, and no READABLE is ever delivered again. Writes keep working because they never needed an event.

Trace evidence from a Windows run (OTTY_LOG_FILE diagnostics): the runtime loop polls fine (waker tokens fire), one Token(0) readable event delivers the motd (599 bytes), then zero socket events for seconds while the server provably sends data (prompt + echoes). The same session parameters against the same server work on Linux, where mio is level-triggered.

This affects any Rust project combining mio-registered sockets with out-of-band raw-socket I/O (like libssh2) on Windows.

Fix

When libssh2 reports WouldBlock (kernel buffer drained), peek one byte through the mio socket:

self.io.peek(&mut [0u8; 1])

The peek hits WouldBlock without consuming data, which is mio's documented signal (TcpStream::peek: "Need to re-register if peek returns WouldBlock to ensure the socket will receive more events once it is ready again") to re-register the full interest set. Applied on both the read and write WouldBlock paths; on unix the helper is a no-op since mio is level-triggered there.

Verification

  • Runtime-verified on a Windows 11 host against an OpenSSH 10.0 server: before the fix the session froze after the motd; after the fix, prompt appears and interactive use (echo, ls, full-screen programs) works normally
  • Unix behavior unchanged (helper is #[cfg(not(windows))] no-op)
  • cargo clippy -p otty-pty --all-targets --all-features -- -D warnings, cargo test -p otty-pty --all-features green

mio's Windows backend is edge-triggered: after delivering an event it
clears the socket's interest bits, and only re-registers them when a
read or write through mio::net::TcpStream hits WouldBlock. All SSH
channel I/O in this crate bypasses mio (libssh2 owns the raw socket),
so after the first READABLE event the interest was never re-armed and
the session never signalled readable again: the terminal displayed the
initial login banner and then went permanently deaf, while writes kept
working because they never needed an event.

Peek one byte through the mio socket whenever libssh2 reports
WouldBlock (kernel buffer drained). The peek hits WouldBlock without
consuming data, which is mio's documented signal to re-register the
socket interest. Unix is unaffected: mio is level-triggered there and
the helper is a no-op.
Copilot AI review requested due to automatic review settings July 31, 2026 23:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a Windows-specific edge-triggered mio readiness issue in otty-pty’s SSH backend where libssh2 performs out-of-band raw-socket I/O, causing mio interests to be cleared after the first event and the session to stop receiving further output.

Changes:

  • Adds a Windows-only helper (rearm_io_events) that calls mio::net::TcpStream::peek to trigger mio’s internal re-registration behavior after WouldBlock.
  • Calls this helper from the SSH Session::read and Session::write WouldBlock paths to keep readiness armed.
Suppressed comments (1)

otty-pty/src/ssh.rs:163

  • Same race as the read path: after a libssh2 WouldBlock, rearm_io_events() can observe peek() == Ok(_) (socket became ready again) and return Ok(()) without necessarily re-arming mio. Returning Ok(0) immediately can then leave pending output stalled until a writable event that might not be delivered. Retrying the write once after re-arming makes progress when the socket becomes writable in-between.
            Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
                self.rearm_io_events()?;
                Ok(0)
            },

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread otty-pty/src/ssh.rs
Comment on lines +144 to +147
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
self.rearm_io_events()?;
Ok(0)
},
Comment thread otty-pty/src/ssh.rs
Comment on lines +70 to +78
/// Re-arm mio's edge-triggered readiness after a raw-socket WouldBlock.
///
/// All channel I/O bypasses mio (libssh2 owns the socket), so mio's
/// Windows backend never observes the WouldBlock it uses as the signal
/// to re-register interest, leaving the session permanently deaf after
/// the first event. Peeking one byte through the mio socket hits
/// WouldBlock once the kernel buffer is drained, which triggers mio's
/// internal re-registration without consuming any data.
#[cfg(windows)]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants