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 线程永远不能等网络。
数据路径
按下 l,UI 把按键编码成字节交给会话层,SSH 库把它加密封装成 SSH_MSG_CHANNEL_DATA 消息(RFC 4254 §5.2)。到了服务器上,sshd 把载荷写进伪终端的 master 端。内核 tty 层的行规程负责回显、行模式下的行编辑,以及信号处理,比如 Ctrl+C 变成 SIGINT。shell 从 slave 端读到输入,输出沿原路返回。
建立这条路径要三个通道请求,都定义在 RFC 4254 里:
pty-req(§6.2):携带终端类型、以字符和像素计的尺寸,以及编码后的终端模式。env(§6.4):可选,第 4 篇专门讲。shell(§6.5):启动用户的登录 shell。
之后改尺寸用 window-change(§6.7)。注意,这里的 window 指终端窗口,和下面要讲的流控窗口没有关系。
SSH 通道窗口是怎么工作的
SSH 在一条 TCP 连接上复用多个通道。为了不让某个高速通道挤占整条连接,或者撑爆对端内存,RFC 4254 §5.2 给每个通道的每个方向都设了一个窗口,表示发送方还能发多少字节。发数据会消耗窗口,接收方消费完数据后用 SSH_MSG_CHANNEL_WINDOW_ADJUST 把窗口补回去。窗口降到零,发送方就必须停下,等对端补窗口。
对终端来说,真正会卡住的是大块写入——一次大粘贴,或第 5 篇那种走终端的上传。客户端一直写到发送窗口耗尽,然后只能等。等的时候做什么,就是关键。
服务器只有在自己这一侧消费了输入之后,才会重新打开客户端的窗口,前提是远端程序在读输入。而交互式程序常常同时也在写:shell 回显粘贴的文本,或者刷新进度显示。如果客户端此时不读服务器的输出,就不会给反方向补窗口,于是:
- 服务器停止发送。
- 远端程序卡在自己的
write()上,不再读输入。 - 客户端的发送窗口一直是零。
双方都动不了。所以写的一方必须同时在读。
大量写入为什么会卡死:边写边排空
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,也处理了库返回值比请求长度还大的情况。
可以看出三个设计选择:
- 写入量永远不超过窗口。 分片大小同时受
write_window().remaining和一个固定上限约束,一次写调用排队的数据不会超过协议允许的量。 - 写不动的时候就读。 窗口为零、写入零字节、返回 would-block,这三种情况都转去
pump_channel_reads。输出一到就转给终端,反方向的窗口也就能持续流动。 - 等待有上限。 只有整轮循环一个字节都没写出去,无进展计数才增加。连续约 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
泵线程每一轮依次做这几件事:
- 清空优先队列。
- 处理积压的尺寸变更。
- 最多等 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,让取消排在队列里还没发出的文件数据之前。
小结
- 一个 SSH shell 由三个 RFC 4254 请求组成:pty-req、env 和 shell;改尺寸用 window-change。
- 通道数据在两个方向上都受窗口限制。大块传输时只写不读的客户端,可能和远端程序互相卡死。写不动的时候就去读。
- 每次等待都要有上限,网络 I/O 全部放在专用线程上,GUI 通过有界的非阻塞队列和它通信。
下一篇是粘贴。同样是大块写入,翻车点不一样: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 中。