SSH 通道流控详解:为什么 SSH 客户端必须边写边读

「SSH 终端是怎样做成的」系列第 2 篇。代码摘自 MistTerm v1.2.26(MistLab 出品的开源 SSH 终端客户端,AGPL 协议),只做删减。

要点

SSH 流控给每个通道的每个方向一个窗口:发送方还能发出的字节数,接收方消费数据后用 SSH_MSG_CHANNEL_WINDOW_ADJUST 补充(RFC 4254 §5.2)。发送窗口为零时,客户端必须继续读取服务端的输出,否则它和远端程序会互相等待、永远卡住。网络 I/O 不要放在 GUI 线程,每次等待都要有上限。

第 1 篇把终端当成一根管子,字节从里面流进解析器。字节从一条连在服务器 PTY 上的 SSH 通道进来,按键也从这条通道出去。这条路径有两个特性,决定了交互式 SSH 客户端的结构:SSH 通道是有流控的,而 GUI 线程永远不能等网络。

数据路径

客户端的线程与队列,服务端的 PTY
客户端的线程与队列,服务端的 PTY

按下 l,UI 把按键编码成字节交给会话层,SSH 库把它加密封装成 SSH_MSG_CHANNEL_DATA 消息(RFC 4254 §5.2)。到了服务器上,sshd 把载荷写进伪终端的 master 端。内核 tty 层的行规程负责回显、行模式下的行编辑,以及信号处理,比如 Ctrl+C 变成 SIGINT。shell 从 slave 端读到输入,输出沿原路返回。

建立这条路径要三个通道请求,都定义在 RFC 4254 里:

之后改尺寸用 window-change(§6.7)。注意,这里的 window 指终端窗口,和下面要讲的流控窗口没有关系。

SSH 通道窗口是怎么工作的

SSH 在一条 TCP 连接上复用多个通道。为了不让某个高速通道挤占整条连接,或者撑爆对端内存,RFC 4254 §5.2 给每个通道的每个方向都设了一个窗口,表示发送方还能发多少字节。发数据会消耗窗口,接收方消费完数据后用 SSH_MSG_CHANNEL_WINDOW_ADJUST 把窗口补回去。窗口降到零,发送方就必须停下,等对端补窗口。

SSH 通道流控:窗口为零时停写,直到对端补窗口
SSH 通道流控:窗口为零时停写,直到对端补窗口

对终端来说,真正会卡住的是大块写入——一次大粘贴,或第 5 篇那种走终端的上传。客户端一直写到发送窗口耗尽,然后只能等。等的时候做什么,就是关键。

服务器只有在自己这一侧消费了输入之后,才会重新打开客户端的窗口,前提是远端程序在读输入。而交互式程序常常同时也在写:shell 回显粘贴的文本,或者刷新进度显示。如果客户端此时不读服务器的输出,就不会给反方向补窗口,于是:

  1. 服务器停止发送。
  2. 远端程序卡在自己的 write() 上,不再读输入。
  3. 客户端的发送窗口一直是零。

双方都动不了。所以写的一方必须同时在读。

大量写入为什么会卡死:边写边排空

MistTerm 把这条规则收敛到一个函数里,所有大块 PTY 写入都经过它,即 src/ssh/manager.rs 里的 write_pty_with_drain。会话处于非阻塞模式(sess.set_blocking(false)),每次写要么有进展,要么立刻返回。循环主体如下:

// 无进展轮询上限:每次 sleep 150µs × 6000 次 ≈ 900ms,≈ 1 秒仍无进展就报错返回。
const MAX_NO_PROGRESS: usize = 6_000;
const CHUNK_CEIL: usize = 256 * 1024;

while !rest.is_empty() {
    let len_before = rest.len();
    let win = channel.write_window().remaining as usize;
    if win == 0 {
        // 没有发送窗口:改为读取输出,让对端得以推进
        Self::pump_channel_reads(channel, read_buffer, message_tx, session_id, upload_bypass, None)?;
    } else {
        let chunk = rest.len().min(win).min(CHUNK_CEIL).max(1);
        match channel.write(&rest[..chunk]) {
            Ok(0) => { /* 排空读取 */ }
            Ok(n) => {
                rest = &rest[n..];
                if !rest.is_empty() { /* 分片之间排空读取 */ }
            }
            Err(e) if Self::is_retryable_write_error(&e) => { /* 排空读取 */ }
            Err(e) => return Err(e),
        }
    }
    if rest.len() < len_before {
        no_progress = 0;
    } else {
        no_progress += 1;
        if no_progress > MAX_NO_PROGRESS { return Err(/* TimedOut */); }
        thread::sleep(Duration::from_micros(150));
    }
}

这里省略了两处:实际代码在每个排空点会把通道关闭映射为 ConnectionAborted,也处理了库返回值比请求长度还大的情况。

可以看出三个设计选择:

  1. 写入量永远不超过窗口。 分片大小同时受 write_window().remaining 和一个固定上限约束,一次写调用排队的数据不会超过协议允许的量。
  2. 写不动的时候就读。 窗口为零、写入零字节、返回 would-block,这三种情况都转去 pump_channel_reads。输出一到就转给终端,反方向的窗口也就能持续流动。
  3. 等待有上限。 只有整轮循环一个字节都没写出去,无进展计数才增加。连续约 0.9 秒毫无进展,写入就报错,把控制权交回去。否则对端一挂,会话线程就会永远卡住。

网络 I/O 不进 UI 线程

第二条规则关乎线程。egui 是即时模式 GUI,UI 线程每帧都要重绘,在这个线程上做任何阻塞调用,窗口都会冻结。而 SSH I/O 天生会阻塞:写入可能停在上面的排空循环里,库调用可能在等 socket。

MistTerm 给每个交互会话配一个专用的系统线程,叫 shell 泵,由它独占通道。UI 和它之间只通过有界队列通信:

let (pump_tx, pump_rx) = sync_channel::<ShellPumpCommand>(SHELL_PUMP_QUEUE_CAP); // 512
let (interrupt_tx, interrupt_rx) = sync_channel::<Vec<u8>>(8);
let (resize_tx, resize_rx) = sync_channel::<(u32, u32)>(RESIZE_QUEUE_CAP);       // 16

泵线程每一轮依次做这几件事:

  1. 清空优先队列。
  2. 处理积压的尺寸变更。
  3. 最多等 8 毫秒取一条命令;超时就处理 keepalive 并读取输出。

相邻的按键命令在发送前会合并成一次最多 32 KiB 的写入,一阵连续敲击只占一个队列条目,而不是几百个。

UI 一侧从不在这些队列上阻塞,入队一律用 try_send:

/// 使用 `try_send`(非阻塞):当 shell 泵线程被 `write_pty_with_drain` 或长读卡住、队列满时,
/// **不阻塞调用方(UI 线程)**,而是返回错误让上层决定丢弃或降级。
fn pump_send(&self, cmd: ShellPumpCommand) -> Result<(), String> {
    self.pump_tx.try_send(cmd).map_err(/* "shell pump queue full (cap=512) …" */)
}

这是一个取舍。阻塞式 send 不会丢输入,但会把 UI 的响应速度和网络绑在一起。try_send 保证窗口始终有响应,代价是在极端积压时报告丢弃了一条命令。队列有 512 个条目,按键又会合并,真到队列满的时候,会话实际上已经卡死了。

容量为 8 的优先队列用于必须插队的控制字节,入口是 send_priority_interrupt。在 v1.2.26 里,它唯一的调用方是 ZMODEM 取消流程(第 5 篇):发送 8 个 CAN、8 个退格和一个 Ctrl+C,让取消排在队列里还没发出的文件数据之前。

小结

下一篇是粘贴。同样是大块写入,翻车点不一样:bracketed paste,还有 4095 字节那一行。

常见问题

SSH 通道窗口是什么?

每个通道每个方向各有一个计数,表示发送方还能发送多少字节(RFC 4254 §5.2)。发送会消耗窗口,接收方消费数据后用 SSH_MSG_CHANNEL_WINDOW_ADJUST 补充。

为什么大段粘贴会让 SSH 会话卡住?

如果客户端只写不读,服务端就停止发送输出,远端程序阻塞在自己的输出上、不再读取输入,客户端的发送窗口一直为零。

SSH 的 window-change 请求和流控有关吗?

无关。window-change(RFC 4254 §6.7)用来通知终端尺寸变化,和流控窗口不是一回事。

交互式 SSH shell 是由哪些通道请求建立的?

pty-req、可选的 env 请求,最后是 shell,都定义在 RFC 4254 中。

相关文章

← 第 1 篇 · 系列目录 · 第 3 篇 →