资源下载地址:
https://download.csdn.net/download/zhaogaojian/93501637
程序主要模块
![]()
一、问题背景
最近在开发 IOTLink 远程终端功能时,遇到了一个比较奇怪的 SSH 问题:
SSH 连接没有断开,远程日志仍然不断刷新,但是键盘输入、粘贴命令都没有反应,甚至按回车也无法正常操作终端。
更奇怪的是,这个问题不是连接后立即发生,而是 SSH 会话持续运行一两个小时后才逐渐出现。
最初怀疑是 SSH 网络异常、终端组件的输入事件处理问题,或者程序运行时间过长导致资源泄漏。
但经过对 WSL 内部 SSH 会话、PTY 设备和进程状态进行检查,最终发现:
问题与为了防止 SSH 断开而实现的命令式保活有关。保活命令不断写入远程终端,当远程前台程序长期不读取标准输入时,PTY 输入数据可能持续积压,最终导致交互异常。
这次问题的排查过程也让我重新认识了 SSH 保活、Linux 伪终端以及标准输入输出之间的关系。
二、故障现象
测试环境如下:
项目 | 环境 |
|---|---|
SSH 客户端 | IOTLink 内置远程终端 |
SSH 服务端 | WSL Linux OpenSSH Server |
远程 Shell | Bash |
终端设备 |
|
前台任务 | 持续输出日志的循环任务 |
保活方式 | 定时向 SSH 终端写入命令字符串 |
故障时间 | 运行约两小时后出现 |
为了模拟持续输出的终端任务,可以执行:
while true; do echo "Running: $(date)" sleep 1 done正常情况下,终端会不断刷新:
Running: Sat Oct 24 14:00:01 Running: Sat Oct 24 14:00:02 Running: Sat Oct 24 14:00:03 ...故障出现以后,观察到几个关键现象:
终端日志继续刷新,输出没有停止。
SSH TCP 连接没有断开。
键盘输入和粘贴无法正常操作终端。
按下回车无法返回 Bash 提示符。
关闭保活功能后,已有 SSH 会话不一定立即恢复。
断开 SSH 后重新连接,输入功能又恢复正常。
需要注意的是,前台持续运行的程序本来就不会因为按下回车而返回 Bash。因此,单独的“按回车无效”并不能证明输入通道异常。
真正值得排查的是:输入是否到达服务端、终端是否还能处理新输入,以及Ctrl+C等控制输入是否仍然有效。
三、SSH 为什么能输出,却不能正常输入?
理解这个问题,首先需要了解 SSH 远程终端的工作机制。
SSH 交互终端并不是将客户端键盘直接连接到 Bash,而是通常通过 Linux 的伪终端 PTY 完成交互。
SSH 交互终端的数据传输结构(示意图)
这里有两个相对独立的方向。
输入方向:
键盘 / 命令式保活 ↓ SSH 客户端 ↓ sshd ↓ PTY 输入处理 ↓ 前台程序读取 stdin输出方向:
前台程序写入 stdout / stderr ↓ PTY ↓ sshd ↓ SSH 客户端 ↓ 终端显示日志这意味着,SSH 输出正常,并不代表远程终端输入也一定正常。
只要前台程序仍然能够写出数据,服务端仍然能够向客户端发送 SSH 数据,终端窗口就可能持续显示日志。
但是,如果输入长期积压、没有被正确读取或处理,用户就可能发现键盘输入无法产生预期效果。
四、真正的诱因:为了保活,向终端发送命令
最初为了避免 SSH 会话在长时间无操作时断开,在客户端加入了一个命令式保活机制。
其原理是:每隔一段时间向远程终端发送一个很简单的 Shell 命令。
例如:
:;printf 'keepalive\r'其中:
:是 Shell 内置空命令。;用于分隔命令。printf用于产生输出。\r在这里是传给printf的转义序列,表示输出回车字符。
实现时可能还会给整个命令追加\r或\n,用于模拟按下回车提交命令。
如果当前 Bash 正在等待用户输入,这种方式通常能够正常执行。
但如果 Bash 正在执行一个长期运行的前台任务,情况就不同了。
例如:
while true; do date sleep 1 done此时,负责解释命令的 Bash 正在执行循环,循环本身没有读取终端标准输入。
保活数据即使已经通过 SSH 到达服务端,也不能因此立即被 Bash 当作下一条命令执行。
这里必须区分两个概念:
数据送达 PTY,不等于命令已被 Shell 执行。
在终端规范模式下,PTY 会对输入进行行编辑和缓冲。输入数据可能保留在终端行规程的缓冲区中,直到有进程读取它。
如果保活持续发送,但没有相应的读取操作,数据就存在积压风险。
五、排查发现:PTY 输入队列达到 4096 字节
通过另一个 WSL 终端检查异常会话,确认 SSH 服务端已经收到了客户端发送的数据。
同时观察到:
TTY: /dev/pts/0 Input queue: 4096 bytesLinux 常见的N_TTY行规程有一个重要限制:规范模式下单行最多 4096 个字符(包括行结束符),非规范模式下读取缓冲区也有相近的容量约束。
The Linux Kernel documentation
故障时观测到的输入队列
4096 / 4096 B
已达到本次观察到的队列容量边界;并不代表所有 Linux TTY 场景都会以完全相同的方式拒绝输入。
需要特别说明:4096 字节是本次故障的重要线索,但不能单凭这个数字就断言sshd的write()一定被阻塞。
因为 Linux TTY 在规范模式下对换行、已完成行、超长输入等具有特殊处理逻辑,且FIONREAD所返回的值与实际内部缓冲占用不一定完全等价。
因此,更严谨的结论是:
本次会话存在明显的 PTY 输入积压,且与命令式保活及前台任务不读取 stdin 的现象吻合。
至于 SSH 写入是否阻塞、具体在哪个系统调用上等待,还需要结合strace或其他系统跟踪结果进一步确认。
为什么偏偏运行两小时后才出现?
这与保活频率、发送内容长度,以及输入是否被读取密切相关。
假设每 30 秒向终端写入 20 字节保活数据,并且这些数据始终没有被读取:
\[ \frac{4096}{20}\times30 \approx 6144\text{ 秒} \]
也就是约 102 分钟。
这只是用于说明积压速度的理想化计算,真实情况会受到行规程、换行和其他输入处理行为影响。
但它解释了为什么问题可能具有明显的延迟性:
SSH 刚连接时完全正常。
运行几十分钟后,仍然看不出问题。
随着保活输入不断累积,终端逐渐出现异常。
最终达到输入处理边界,用户发现交互无法正常使用。
这类故障最容易被误认为连接超时、网络故障或客户端资源泄漏。
六、故障恢复:如何清空 PTY 中积压的数据?
出现问题以后,首先要关闭客户端的命令式保活,避免继续产生新的输入。
如果 SSH 终端已经无法正常操作,可以打开另一个 WSL 窗口进行排查。
1. 确认异常终端设备
执行:
ps -ef | grep '[s]shd'查看终端和前台进程:
ps -eo pid,ppid,tty,stat,args | grep 'pts/'也可以执行:
who找到对应的 SSH 会话,确认它实际使用的是哪个/dev/pts/N。
这里假设异常会话使用/dev/pts/0。
2. 检查输入队列
Linux 下可以通过FIONREAD查询终端当前可读取的输入字节数:
sudo python3 - <<'PY' import os import fcntl import array import termios path = "/dev/pts/0" fd = os.open( path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOCTTY ) try: buf = array.array("i", [0]) fcntl.ioctl(fd, termios.FIONREAD, buf, True) print("当前可读取字节数:", buf[0]) finally: os.close(fd) PY需要注意,FIONREAD表示当前可供读取的数据量,并不一定等于 TTY 内部所有尚未完成的输入缓冲占用。因此,不能将它当作通用的 PTY 缓冲区使用率监控指标。
3. 清理输入队列
Python 的termios.tcflush()可以丢弃指定终端中等待处理的输入数据。
Python 3.14.0a2 documentation
执行:
sudo python3 - <<'PY' import os import termios path = "/dev/pts/0" fd = os.open( path, os.O_RDWR | os.O_NOCTTY ) try: termios.tcflush(fd, termios.TCIFLUSH) print("PTY 输入队列清理完成") finally: os.close(fd) PY其中:
参数 | 作用 |
|---|---|
| 丢弃等待读取的输入数据 |
| 丢弃等待发送的输出数据 |
| 同时清理输入和输出 |
此次只需要处理输入,所以使用TCIFLUSH。
必须提醒的是,这项操作会丢弃对应 TTY 中尚未读取的输入,包括正常输入的命令。并且,从另一个进程打开 PTY Slave 进行清理属于故障恢复手段,可能受到权限、终端状态等因素影响,并不能保证所有系统都能以这种方式恢复。
4. 结束前台循环
清理输入队列后,尝试回到异常终端,按下:
Ctrl+C正常情况下,终端驱动会将中断信号发送给前台进程组,使循环终止并返回 Bash。
如果控制键仍然无法生效,可以在另一个终端确认具体 PID 后结束对应任务。
也可以直接重新建立 SSH 连接,创建新的 PTY。
需要注意:SSH 重连只是恢复交互,不代表保活实现的问题已经解决。 如果仍然定期向远程终端写入命令,同样的问题可能再次发生。
七、为什么直接在前端输入保活字符串,也不能解决?
排查过程中产生了一个想法:
既然通过后端发送保活命令会造成问题,那么将保活字符串放在前端,由终端组件模拟用户输入,是否能够规避?
答案是:只要最终仍然通过 SSH Channel Data 写入远程 PTY,就无法从根本上解决。
例如下面两种实现。
方式一:后端定时保活。
后端定时器 ↓ SSH Channel Write ↓ 远程 PTY方式二:前端模拟终端输入。
前端定时器 ↓ 模拟用户键盘输入 ↓ SSH Channel Write ↓ 远程 PTY两者只是发送数据的起点不同,最终都进入同一个远程输入通道。
如果前端只是在本地显示一串字符,而不将它发送给 SSH 服务器,也不能起到真正的 SSH 协议保活作用。
因此:
保活应当与终端输入解耦,而不是换一个位置继续注入字符串。
八、正确方案:使用 SSH 协议级保活
OpenSSH 本身就具有保活机制,不需要向远程 Shell 注入命令。
例如,使用 OpenSSH 客户端连接时:
ssh \ -o ServerAliveInterval=30 \ -o ServerAliveCountMax=3 \ user@192.168.1.100其中:
ServerAliveInterval=30表示在指定时间内没有收到服务端数据时,客户端发起 SSH 协议层保活检测。
ServerAliveCountMax=3表示连续多次没有收到保活响应时,可以将连接判定为不可用。
但是根据我观察,上面感觉不能彻底解决主动断开问题。
另外设置TIMEOUT=0也可以
这些保活消息通过 SSH 加密通道发送,并不作为键盘输入交给远程 Bash。
Linux manual page
也可以在 SSH 服务端配置:
ClientAliveInterval 30 ClientAliveCountMax 3这是从服务端方向进行连接活性检测的机制。
OpenBSD manual pages
不过,客户端和服务端配置的作用方向不同,不是简单的替代关系。
另外,保活机制主要用于发现死连接、保持连接活动,并不保证所有网络设备都不会主动回收空闲连接。
自研 SSH 客户端应该如何处理?
对于 IOTLink 这样的内置 SSH 客户端,推荐采用下面的架构:
推荐架构:终端交互和保活机制分离
核心设计原则是:
用户输入只负责终端交互。
保活使用 SSH 协议消息,而不是 Shell 命令。
保活请求不应污染 PTY 输入缓冲区。
如果连接已经无响应,应进行状态检测和恢复,而不是无限积压发送数据。
终端没有用户输入时,不代表 SSH 会话必须停止通信。
如果底层使用libssh2,可以考虑其内置的 Keepalive 接口,例如:
libssh2_keepalive_config(session, 1, 30);然后在合适的调度位置调用:
int seconds_to_next = 0; int rc = libssh2_keepalive_send( session, &seconds_to_next ); if (rc != 0) { // 记录错误并按连接状态进行处理 }实际工程中还需要正确处理会话线程安全、非阻塞操作,以及EAGAIN等返回情况。
如果使用的是libssh、Paramiko 或其他 SSH 库,也应该优先使用相应库提供的保活功能。
九、如何验证修复是否真正有效?
修复以后,不能只测试几分钟就认为问题已经解决。
建议保留持续输出场景,并完成以下验证。
测试项目 | 预期结果 |
|---|---|
连续运行 2 小时 | SSH 保持连接,输入正常 |
连续运行 8 小时 | 无持续输入积压 |
运行前台无限循环 | 不自动注入 Shell 命令 |
用户按 | 正常中断前台任务 |
长时间没有输出 | 保活按配置正常工作 |
网络短暂中断 | 能检测断线并提示或重连 |
大量终端输出 | 键盘输入仍可正常处理 |
粘贴长文本 | 能正确处理输入和背压 |
需要强调的是,PTY 的缓冲占用与应用是否正确读取输入有关。因此,验证时不应仅依靠单一的 4096 字节阈值判断成功或失败。
更重要的是确认:保活消息不再进入远程标准输入,并且用户交互不会因为保活机制而逐渐失效。
十、总结
这次 SSH 故障非常具有迷惑性。
从表面看,SSH 没有断开,日志还在正常输出,似乎网络、服务端和远程程序都没有问题。
但进一步检查发现,输入方向存在积压现象,而最初为了提高连接稳定性加入的命令式保活,反而成为了潜在故障来源。
最终得到了几个重要结论:
SSH 能正常输出,不代表终端能够正常处理输入。
PTY 输入缓冲区和远程 Shell 执行命令是两个不同的环节。
长期运行的前台任务不读取 stdin 时,向终端注入保活命令存在积压风险。
将保活逻辑从后端移动到前端,并不能改变数据最终进入 PTY 的事实。
通过
tcflush()可以尝试清理积压输入,但这是临时故障恢复方法。更可靠的方案是使用 SSH 协议级保活,并与正常终端输入完全分离。
这次问题最值得记住的一点是:不要为了保持 SSH 连接,就定时向用户的终端输入命令。
SSH 保活属于连接管理,键盘输入属于终端交互,两者不应该混在一起。
对于需要长期稳定运行的远程终端工具,特别是工业设备维护、远程 Linux 管理、持续日志监控等场景,正确处理 SSH 保活、PTY 和输入输出通道,比单纯保证 TCP 连接不断开更加重要。