文章

【笔记】TTY / PTY / Line Discipline 与 SSH 终端链路排查

【笔记】TTY / PTY / Line Discipline 与 SSH 终端链路排查

1. 背景

讨论从一个具体问题出发:docker exec -i-t 分别做什么、二者的关系。展开后依次讨论了 TTY/PTY 的分层模型、line discipline 是什么、这套机制在真实的 macOS iTerm2 → SSH → Linux 服务器链路里如何工作(信号转发、窗口 resize),最后用一次 kill -9 杀死远程 vim 后本地终端花屏的真实案例,验证并纠正了对该机制的理解。

2. 核心问题

  1. docker exec -i-t 各自的作用是什么,为什么交互式操作通常要 -it 一起用?
  2. TTY/PTY 是什么模型,”line discipline” 在其中扮演什么角色?
  3. 在一次真实的 iTerm2 → ssh → 远程 shell 链路里,有几个 PTY、几个关键进程,数据(普通输入、信号、窗口变化)是怎么流转的?
  4. TTY 的模式(raw/cooked)能不能在进程运行期间动态切换?切换范围是进程私有的还是设备全局的?
  5. kill -9 杀掉一个 TUI 程序后终端花屏,根因是什么,和前面讨论的 TTY 模式切换是不是同一层问题?

3. 当前理解

3.1 docker exec -i / -t

  • -i--interactive):保持容器进程的 stdin 打开,不加的话 stdin 会被关闭/重定向到空设备,本地敲的内容传不进容器进程。
  • -t--tty):给容器进程分配一个伪终端(PTY),让它的 stdin/stdout/stderr 表现为连在一个终端设备上(isatty() 为真、有行缓冲/回显、能收窗口尺寸变化信号等)。
  • 二者正交、可任意组合,但语义上互补:-i 决定”有没有数据能写进 stdin”,-t 决定”这个 stdin/stdout/stderr 是不是一个终端”。交互式 shell 几乎总要 -it 同时用。
  • 关键的一点容易被忽视:-t 一旦启用,stdout 和 stderr 会共享同一个 PTY,不再是两条独立通道。Docker attach 协议(moby API 文档)写明:TTY 模式下传输的是”PTY 的原始数据流”,只有 TTY 关闭时才会按 stream type 头部区分 stdout/stderr 做多路复用。这也是为什么脚本化调用容器命令时通常故意不加 -t——需要保留 stdout/stderr 分离。

3.2 TTY/PTY 分层模型

不是简单的”数据中继”,而是三层结构:

1
2
3
4
5
终端模拟器(真实终端 / docker client)
        ↕  PTY master
   [ 内核 line discipline,默认 N_TTY ]
        ↕  PTY slave
      进程(shell / 容器内进程)

line discipline 是内核 TTY 子系统里插在”设备驱动”和”用户态 read()/write()”之间的可插拔模块(可用 ioctl(TIOCSETD) 切换,交互终端场景基本只会遇到默认的 N_TTY)。它不是被动转发,而是主动解释字节流:

  • 模式切换:由 termios 配置驱动 cooked(canonical)/raw 两种行为。cooked 模式下,输入先进行缓冲,支持 backspace/Ctrl-U 等行内编辑,遇到 \n 才整行提交给上层 read();raw 模式不缓冲,字节到即可读。
  • 回显(echo):cooked 模式下,敲的字符会被 line discipline 自动写回输出端——这是内核做的,不是应用程序自己 echo 的。
  • 信号翻译:识别 termios 里配置的控制字符(INTR=Ctrl-C、QUIT=Ctrl-\、SUSP=Ctrl-Z、EOF=Ctrl-D),命中时不把字节交给上层,而是直接给这个终端会话的前台进程组发对应信号(SIGINT/SIGQUIT/SIGTSTP),或让 read() 返回 0。
  • 窗口尺寸:终端模拟器 resize 时通过 ioctl(TIOCSWINSZ) 写入新尺寸,内核更新 winsize 结构并给前台进程组发 SIGWINCH

3.3 “line discipline” 词源

discipline 在这里取的是它较少见但仍规范的义项——”一套约束行为的规则体系”(a set of rules that governs behavior),跟”纪律训练”的常见义无关。历史上贝尔实验室 Unix(1970s)把 TTY 驱动栈分为”底层驱动”+”上层协议处理”两段,上层这段规定字节流该被如何解释(是否缓冲成行、是否回显、是否把特定字符转成信号),就叫 line discipline,字面即”(对一行输入的)处理规范”。现代 Linux 里对应 tty_ldisc 子系统,且这套 discipline 可在运行时替换(默认 N_TTY,也有 N_PPP 等)。

3.4 真实链路分析:macOS iTerm2 → SSH → Linux 服务器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
macOS(iTerm2)                          Linux 服务器(sshd)
┌─────────────────────┐                ┌──────────────────────┐
│ iTerm2(GUI 进程)     │                │ sshd 主进程(监听)      │
│  持有 PTY-A master   │                │   └─fork→ sshd 会话子进程│
│        │             │                │        持有 PTY-B master│
│      PTY-A           │                │             │          │
│        │             │                │           PTY-B        │
│  slave = 本地 zsh 的  │                │             │          │
│  控制终端            │                │      slave = 远程 bash  │
│  zsh --fork/exec-->  │   加密 SSH 连接  │      的控制终端         │
│  ssh 客户端进程       │ ────网络─────→  │      bash(及其子进程,  │
│  (继承同一个 slave fd  │ ←────────────  │       如 vim)          │
│   做 stdin/out)      │                │                        │
└─────────────────────┘                └──────────────────────┘
  • PTY 共 2 个:PTY-A(本地,master 在 iTerm2 手里,slave 是本地 zsh 的控制终端)、PTY-B(远程,master 在 sshd 会话子进程手里,slave 是远程 bash 的控制终端)。
  • 进程链:本地 iTerm2 → zsh → ssh 客户端;远程 sshd 会话子进程 → bash → 后续命令(vim 等)
  • 关键点ssh 客户端自己不持有任何 PTY 的 master 端,它只是继承了 PTY-A 的 slave fd 做自己的 stdin/stdout/stderr。
  • 两层 line discipline 分工不同ssh 客户端一旦要在远端分配 pty,会 tcsetattr 把本地 PTY-A slave 切成 raw 模式,本地这层基本不做编辑/回显/信号翻译,只是把字节原样传给远端;真正的行编辑、回显、Ctrl-C→SIGINT 翻译发生在远程 PTY-B 的 line discipline 上(除非远程正在跑 vim 之类把它也切成 raw 的程序)。
  • 回显路径:本地敲的字节 → 原样传到远程 → 远程 line discipline 回显 → 经 sshd → 网络传回 → ssh 客户端写回本地 PTY-A slave → iTerm2 读 master 显示。即回显往返了一次网络,这也是高延迟场景下”打字感觉卡顿一下才出字”的原因。
  • 信号路径(Ctrl-C):本地按下 Ctrl-C 产生的 0x03 字节,本地 raw 模式不拦截,原样传到远程;远程 PTY-B 的 line discipline 识别出 INTR 字符后,直接给该终端前台进程组发 SIGINT。信号是在远程产生的,不是本地。
  • 窗口 resize 路径:本地窗口变化 → 本地内核对 PTY-A 发 SIGWINCH → ssh 客户端(其控制终端正是 PTY-A)捕获后,通过 SSH 协议的 window-change channel request(应用层消息,不混在字节流里)通知 sshd → sshd 对 PTY-B master 做 ioctl(TIOCSWINSZ) → 远程内核更新 winsize 并给远程前台进程组发 SIGWINCH

3.5 交互式 shell 的行编辑与 TTY 模式的动态切换

  • TTY 模式挂在设备本身,不是进程私有状态termios 设置是内核里 tty 结构体的一部分,任何有权限、持有该 fd 的进程都能随时 tcsetattr() 修改,立即对该 tty 上所有读写生效。
  • 规范的终端程序遵循 save/restore 约定:进入时 tcgetattr() 保存现场,退出前用保存的值恢复。sshvimlesstop 都这样做,这是编程约定,内核不强制。
  • zsh/bash 自身也会切 TTY 模式:这类自带命令行编辑器(zsh 的 ZLE、bash 的 readline)的 shell,在等待输入的 prompt 状态下,会把 tty 切到接近 raw(non-canonical/cbreak)的模式,因为要自己逐字符接管按键实现历史翻页、Tab 补全、语法高亮,这些内核 cooked 模式不支持。更完整的时间线是:zsh(ZLE raw-ish) → fork 外部命令前恢复默认 cooked → 前台命令(如 ssh)自己再存现场并切 raw → 命令退出恢复到那份 cooked → zsh 拿回前台后再次切回 ZLE 模式
  • 异常退出会导致状态残留:若前台程序被 kill -9SIGKILL)杀死,没有机会执行恢复步骤,tty 会卡在它设置的那个模式(通常是 raw),表现为 shell 不回显、方向键乱码。手动 stty sanereset 可以把 termios 刷回合理默认值。

3.6 真实案例:kill -9 vim 后终端花屏

kill -9 杀死远程一个 vim 进程后,观察到本地 shell 大量刷出 zsh: command not found: 0zsh: command not found: 173 等乱码提示,且后续鼠标操作还会持续追加类似乱码行。iTerm2 顶部同时弹出提示:”Looks like focus reporting was left on when an ssh session ended unexpectedly or an app misbehaved.”

根因是两套完全独立的机制被搞脏,且是不同层面的问题

  • 前几节讨论的 raw/cooked、回显、信号翻译,全部属于内核 tty 驱动 + line discipline 这一层,靠 ioctl/tcsetattr 配置。
  • vim 进入编辑模式时,还会做另一件事:向 stdout 发送若干 DECSET 转义序列,命令终端模拟器本身切换行为模式,这是终端模拟器协议层的状态,和内核 termios 是两条平行轨道:
    • \e[?1000h(外加常见的 \e[?1002h/\e[?1003h/\e[?1006h):开启鼠标事件报告,此后鼠标移动/点击会被终端模拟器编码成转义序列,当作”键盘输入”注入这个 pane 的 stdin;
    • \e[?1004h:开启焦点报告(focus in/out 事件同样编码后注入 stdin);
    • \e[?1049h(或 1047):切换到 alternate screen(vim 全屏编辑用的”备用屏幕”)。 (以上 DECSET 参数编号已用 xterm.js VT Features 文档和 XFree86 ctlseqs.html 交叉核对,属于 xterm 系终端模拟器的通用约定,iTerm2 兼容实现。)
  • 正常退出时,vim 会发对应的”关闭”转义序列(\e[?1000l\e[?1004l\e[?1049l)把 iTerm2 的模式改回去。kill -9SIGKILL,进程没有机会执行任何清理代码,这些”关闭”指令永远没发出去,iTerm2 因此一直卡在”把鼠标/焦点事件转成输入注入 stdin”的模式。
  • 花屏的具体成因:鼠标事件被 iTerm2 编码成类似 \e[<0;173;8M 的转义序列,走的是和前文回显/信号完全一样的转发链路——写入本地 PTY-A slave(当作你敲的字符)→ ssh 客户端原样加密转发 → sshd 写入远程 PTY-B master → 远程 zsh 读到。zsh 完全不知道这是鼠标事件,只当成命令行文本,转义序列头部的控制字符被吃掉/异常显示,剩下的数字尾巴被当成一条条命令提交执行,于是刷出 zsh: command not found: <数字片段>

处理方式:

  • 直接响应 iTerm2 的提示条(点 Yes)关闭它本地识别到的 focus reporting 状态;
  • 或在远程 shell 手动发一次”全部关闭”转义序列:printf '\e[?1000l\e[?1002l\e[?1003l\e[?1006l\e[?1004l\e[?1049l'
  • 更省心地直接 reset,它会同时把 termios 也拉回 sane 状态,一次解决两层问题;
  • 治本:避免直接 kill -9 一个 TUI 程序(vim/less/top/tmux 等),优先 kill -TERM 或在程序内正常退出,给它机会跑清理逻辑。

3.7 PTY pair 的典型样板代码

POSIX 规定了一套标准的 PTY 创建流程(posix_openpt/grantpt/unlockpt/ptsname),几乎所有终端模拟器、ssh 服务端、docker daemon、tmux 内部都是这套流程的变体。下面是简化后的样板(C,来自 POSIX 规范 posix_openpt 手册页的示例并补充了子进程接管终端的常见做法,属于教学性简化代码,实际生产实现会有更多错误处理和平台差异):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <termios.h>

int main(void) {
    int masterfd;
    char *slave_name;

    /* 1. 打开 master 端,得到一个 fd,同时不让它成为当前进程的控制终端 */
    masterfd = posix_openpt(O_RDWR | O_NOCTTY);
    if (masterfd == -1) exit(1);

    /* 2. 授权 + 解锁 slave 设备(否则 slave 打不开),并拿到 slave 路径 */
    if (grantpt(masterfd) == -1 || unlockpt(masterfd) == -1) exit(1);
    slave_name = ptsname(masterfd);   /* 例如 /dev/pts/7 */

    pid_t pid = fork();
    if (pid == 0) {
        /* ---- 子进程:即将成为这个 PTY 的"从端使用者"(如 shell) ---- */
        close(masterfd);              /* 子进程不需要 master 端 */

        setsid();                     /* 开一个新会话,脱离原控制终端 */

        int slavefd = open(slave_name, O_RDWR);
        ioctl(slavefd, TIOCSCTTY, 0); /* 把这个 slave 设为新会话的控制终端 */

        dup2(slavefd, STDIN_FILENO);  /* 子进程的 stdin/stdout/stderr */
        dup2(slavefd, STDOUT_FILENO); /* 全部指向 PTY slave */
        dup2(slavefd, STDERR_FILENO);
        close(slavefd);

        execlp("/bin/bash", "bash", NULL);  /* 子进程从此就是 PTY slave 的"住户" */
        _exit(1);
    }

    /* ---- 父进程:持有 master 端,扮演"终端模拟器"角色 ---- */
    /* 典型职责:
       - 从 masterfd 读字节,画到屏幕上(终端模拟器的渲染循环)
       - 把用户按键写进 masterfd(转发给子进程的 stdin)
       - 需要时用 tcsetattr()/ioctl(TIOCSWINSZ) 控制这个 PTY 的模式和窗口尺寸 */
    char buf[256];
    ssize_t n;
    while ((n = read(masterfd, buf, sizeof(buf))) > 0) {
        write(STDOUT_FILENO, buf, n);
    }
    return 0;
}

对照理解:

  • 父进程 = 终端模拟器(iTerm2/docker 客户端/sshd 主进程扮演的角色),只持有 master fd,负责渲染和转发。
  • 子进程 = 被接管的程序(shell/容器进程/远程 bash),通过 dup2 把 stdin/stdout/stderr 全部指向 slave fd,从此这个进程认为自己”连在一个真实终端上”。
  • setsid() + ioctl(TIOCSCTTY) 这两步是让 slave 成为子进程新会话的控制终端(controlling terminal)——只有成为控制终端后,Ctrl-C 等信号才能被 line discipline 正确路由到这个会话的前台进程组,这也解释了 3.2 节里”信号翻译”依赖的前提条件。
  • glibc 也提供了封装好的便捷函数 forkpty()<pty.h>),一次调用把上面的 posix_openpt+grantpt+unlockpt+fork+setsid+dup2 全部做掉,很多实际项目(包括不少终端模拟器和 tmux)会直接用它而不是手写完整流程。
  • 这段代码没有处理窗口尺寸同步、错误处理、SIGCHLD 回收等,只用来建立”master/slave 各自角色”的直观认识;对照回看 3.4 节的 SSH 链路图,PTY-A、PTY-B 都是各自宿主机上跑的这样一段逻辑(sshd 侧还要多一层网络转发)。

3.8 程序会不会根据 stdio 类型改变行为:会,而且很常见

这不是理论假设,而是大量真实程序的既有设计——程序可以在运行时用 isatty(fd)ttyname/fstat 判断字符设备类型也可以达到类似效果)检测自己的 stdin/stdout/stderr 是否连接着真实终端,并据此切换输出格式乃至整体行为

  • stdio 缓冲策略:C 标准库(glibc)对 stdout 的默认缓冲模式取决于 isatty(fd)——连着终端时是行缓冲(遇到 \n 就刷新,交互体验流畅);重定向到文件或管道时自动切成全缓冲(攒够一块内存才刷新,吞吐更高但看不到实时输出)。这也是为什么 some_program | tee log.txt 里输出经常”卡住半天才刷一大段”的常见原因,需要程序自己 setvbuf(stdout, NULL, _IONBF, 0) 强制关闭缓冲,或者外部借助 PTY 伪装成终端来骗过这个判断(script/unbuffer/stdbuf 等工具的原理正是”分配一个假 PTY 让程序以为自己连着终端”)。
  • 颜色/高亮输出gitls --color=autogrep --color=auto、大多数现代 CLI 默认只在 isatty(stdout) 为真时输出 ANSI 颜色转义序列,管道/重定向场景自动降级为纯文本,避免颜色控制字符污染文件或下游程序的解析。
  • 进度条/交互式 UIpipdocker buildcurl(默认进度条)、npm install 等在检测到 stdout/stderr 不是 tty 时,会切换成纯日志式的逐行输出,而不是绘制会动态刷新、覆盖同一行的进度条——因为进度条依赖”能随意移动光标重绘”这个终端能力,管道场景下没有意义甚至会产生大量控制字符垃圾。
  • 交互式 prompt / 密码输入:很多程序要求 stdin 必须是 tty 才会弹出交互确认或密码输入(例如 sudossh 的密码认证、git 某些场景),检测到非 tty 时会直接报错或转而读环境变量/参数,避免在脚本化场景卡死等待永远不会来的输入。
  • 一屏输出 vs 一行一条ls 连终端时按列对齐展示;重定向或管道时自动切换成每行一个文件名,方便 xargs/awk 处理。git log/man/less 类命令连终端时会自动起分页器(pager),非交互场景则直接把全部内容吐给 stdout。

这几节讨论的 -t/PTY 分配之所以重要,本质就是为了让远端/容器内的程序在 isatty() 检测时得到”是”的答案,从而触发它为交互场景设计的那套行为(颜色、进度条、行缓冲、prompt);如果不分配 PTY(比如 docker exec 不加 -t、或者脚本化跑 ssh host cmd),这些程序会规规矩矩地表现成”批处理模式”,这也是本文 3.1 节提到”脚本化调用故意不加 -t“背后真正的动机——不是单纯图省事,而是主动选择让程序进入更适合脚本消费的输出模式。

3.9 补充:docker exec -t 到底影响本地还是远程

延续 3.1 的讨论,-t 常被简化理解为”只是让 daemon 侧给容器分配一个 PTY 中继”,但结合 docker/cli 源码(cli/command/container/run.go / exec.go)看,这个理解只对了一半:

  • -tconfig.Tty)本身确实是发给 daemon 的请求参数,语义是”请在容器那一侧分配一个 PTY”,这部分是远程动作,没有错。
  • 但 docker 客户端在本地是否要做配合动作,取决于源码里这样一个联合判断(run.go 可见):

    1
    2
    3
    
    if config.Tty && dockerCli.Out().IsTerminal() {
        // 触发 MonitorTtySize + SetRawTerminal
    }
    

    即:本地是否切 raw、是否监听 resize,由 -t 参数本地 stdout 是否为 ttyIsTerminal(),对应 isatty())共同决定,二者缺一都不会触发。这与之前讨论的”本地那个 tty 是 zsh fork 子进程时天然继承来的,默认是 cooked 模式”并不矛盾——docker 客户端进程继承的 tty 本身默认是 cooked,raw 化是 docker 客户端主动调用 SetRawTerminal 做的,不是”自带”的状态,也不是 -t 之外的独立开关。

  • 验证前提:如果本地 stdout 不是 tty(比如管道、CI),传 -t 会直接报错 the input device is not a TTY——这说明 -t 的可用性依赖本地终端状态,并不是”只管远程、不碰本地”。
  • 本地配合的两件事
    1. SetRawTerminal 把继承来的本地 tty 从 cooked 切成 raw,让本地不再自己做行编辑/回显,字节原样透传给 docker 客户端进程,再转发给容器里新分配的 PTY;
    2. 起一个 goroutine 做 MonitorTtySize,监听本地 SIGWINCH,通过 API 把新尺寸同步给容器的 PTY(container_resize)。
  • 和 SSH 链路完全同构docker exec -t 里的”docker 客户端”对应 SSH 里的”ssh 客户端”,”容器里新分配的 PTY”对应”远程 sshd 分配的 PTY-B”,”本地 SetRawTerminal”对应”ssh 客户端 tcsetattr 切 raw”,”container_resize API 调用”对应”SSH window-change channel request”。两套机制的分工模式一致:真正的行编辑/回显/信号翻译发生在远端(容器 PTY 或远程 PTY-B),但本地这一端并非被动旁观,而是主动切 raw 配合透传。

更准确的表述:-t 决定”要不要在远端分配 PTY”,这是它的直接语义;但该参数是否能生效、是否触发本地切 raw + 监听 resize,取决于本地终端状态这个独立前提,本地并非”不受影响”。

4. 关键概念

概念一句话解释
PTY(pseudo-terminal)内核提供的一对虚拟字符设备(master/slave),让”没有真实硬件终端”的场景(远程登录、容器、终端模拟器)也能获得终端语义
line discipline挂在 PTY/TTY 驱动和用户态读写之间的可插拔内核模块(默认 N_TTY),负责行编辑、回显、控制字符→信号翻译
termios描述一个 tty 当前行为参数的内核结构体,stty/tcsetattr 操作的就是它;raw/cooked 只是它众多参数组合出的常见状态,不是独立开关
cooked(canonical)模式line discipline 缓冲整行、支持行内编辑、自动回显,按 \n 提交给上层
raw 模式不缓冲、不编辑、不自动回显,字节直接可读,应用程序自己接管所有按键语义
DECSET/DECRST终端模拟器协议层的模式开关指令(CSI ? Pm h/l),管的是鼠标报告、焦点报告、alternate screen 等,和内核 termios 是完全独立的两条轨道
SSH window-change requestSSH 协议里专门的 channel request 类型,用于把本地终端 resize 事件同步给远程 PTY,不是靠字节流里混控制字符实现
IsTerminal() / isatty()判断一个文件描述符是否连接着真实终端设备的调用;docker exec -tssh 分配 PTY 前都会先检查本地这一端满足此条件,否则报错或退化行为
controlling terminal一个会话(session)关联的”主控终端”,只有成为控制终端的 PTY slave 才能让 line discipline 把 Ctrl-C 等特殊字符翻译成信号发给该会话的前台进程组;通过 setsid() + ioctl(TIOCSCTTY) 建立
forkpty()glibc 提供的便捷封装,一次调用完成”开 PTY master + fork + 子进程接管 slave 为控制终端”,等价于手写 posix_openpt/grantpt/unlockpt/fork/setsid/dup2 那一整套流程

5. 依据、验证与修正

  • docker exec -i/-t 语义:依据 Context7 拉取的 /docker/docs 官方文档(docker run/docker compose exec 参考页)核实,-i 保持 stdin 打开、-t 分配 PTY 的说法与官方描述一致;-t 下 stdout/stderr 共享同一 PTY、不再多路复用这一点,依据 moby API 文档(v1.14/v1.21 attach 接口说明)关于”TTY enabled 时是 raw PTY 数据流,disabled 时才按 stream type 头部多路复用”的描述,属于已核实的事实。
  • line discipline 的分层模型、raw/cooked 行为、信号与窗口尺寸处理:属于 Unix/Linux TTY 子系统的经典设计,讨论中未逐条查阅内核源码或 POSIX termios 规范原文,是基于已确立的通用知识复述,可视为高置信度但未在本次逐条核对一手规范文档的内容。
  • SSH 链路的 PTY 分布、window-change 走独立 channel request:符合 SSH 协议(RFC 4254 第 6.7 节定义 window-change 是独立的 channel request 类型)的常见描述,本次讨论未直接翻阅 RFC 原文核对,视为个人理解,依据是对 SSH 协议设计的既有认知,未做本轮一手核验。
  • DECSET 参数编号(1000/1002/1003/1004/1006/1049):已用 Tavily 搜索交叉核对 xterm.js 官方 VT Features 文档与 XFree86 ctlseqs.html(xterm 控制序列权威参考),两份来源对参数编号和语义一致,视为已验证
  • zsh ZLE / bash readline 在 prompt 状态下切换 tty 到 raw-ish 模式:这是对 shell 行编辑器实现机制的个人理解性推断,未查阅 zsh/readline 源码或官方文档逐条验证,标注为待验证。
  • iTerm2 实际弹出的提示文案:来自用户提供的真实截图,是一手材料,可直接采信。
  • docker exec -t 本地/远程分工:已用 GitHub 源码搜索定位到 docker/cli 仓库 cli/command/container/run.goif config.Tty && dockerCli.Out().IsTerminal() 这一判断逻辑,确认”本地是否切 raw + 监听 resize”由 -t 参数与本地 IsTerminal() 联合决定,视为已用一手源码核验;但未逐行读完整个 SetRawTerminal/MonitorTtySize 实现细节,具体调用时序仍是基于搜索结果片段的合理推断。
  • PTY pair 样板代码posix_openpt/grantpt/unlockpt/ptsname 这套核心 API 及其示例代码依据 POSIX.1-2017 官方规范(man7.org 收录的 posix_openpt(3p) 手册页,引用了 IEEE/The Open Group 版权文本)核实,视为已验证setsid+ioctl(TIOCSCTTY)+dup2 接管控制终端的写法是根据 Unix 编程通用实践补充的教学性简化代码,未逐条对照 Linux tty_ioctl(4) 手册页核验每个 ioctl 参数的确切行为,标注为待验证;forkpty() 的存在和作用为既有认知复述,未查阅 glibc 手册原文核对签名细节。
  • 程序依据 stdio 类型改变行为:stdio 缓冲策略(终端下行缓冲、非终端下全缓冲)依据 glibc stdio 手册页描述及多篇技术博客交叉印证(”Buffering in pipes”、setvbuf 手册页摘录),视为已验证git/ls --color=auto/进度条工具等依据 isatty() 切换颜色/进度条/分页器行为的具体列举,属于对常见 CLI 工具行为的个人观察总结,未逐一查阅每个工具的源码或官方文档确认判断逻辑的实现细节,标注为待验证(但这是广泛可复现的现象,可自行用 | cat 重定向验证)。

6. 不确定点

  • SSH window-change request 的协议细节(字段格式、是否所有 SSH 客户端/服务端实现都遵循同一行为)未查阅 RFC 4254 原文核对。
  • zsh ZLE 具体在什么时机、以什么 termios 参数组合切换模式(是完全 raw 还是 cbreak 变体),未看 zsh 源码,只是合理推断。
  • 未验证 iTerm2 对 DECSET 1000/1002/1003 系列鼠标协议的具体兼容范围(是否所有子模式都支持),只确认了 1004(focus)和 1049(alt screen)在本次案例里确实被触发。
  • 本文只覆盖了 xterm 系终端模拟器(iTerm2 兼容)的行为,未涉及其他终端模拟器(如 Windows Terminal、GNOME Terminal)在协议层实现上是否存在差异。
  • docker/cliSetRawTerminal/MonitorTtySize 具体实现(如是否有额外的错误处理、exec 场景和 run 场景的代码路径是否完全一致)未逐行通读源码,只核对了触发条件的那一行判断逻辑。
  • 3.7 节样板代码里 setsid()/ioctl(TIOCSCTTY) 的具体行为未逐条对照 Linux tty_ioctl(4) 手册页核验,只是通用编程实践的复述;未在真实机器上编译运行验证这段代码能否直接工作。
  • 3.8 节列举的具体工具(git/ls/pip/docker build 等)判断 isatty() 后切换行为的说法,未逐一查阅各工具源码确认,是基于可复现观察的归纳,不排除个别版本/配置下行为有差异。

7. 复习索引

  • 一句话记忆-i 管 stdin 有没有数据,-t 管这条 stdin/stdout/stderr 是不是终端;两者独立,交互式操作一般要一起开。
  • 易混点-t 开启后 stdout/stderr 会合并成一路 PTY 数据流,不是”只影响输出好不好看”。
  • 易混点:line discipline 不是被动转发,是内核里主动拦截/解释字节流的一层(回显、行编辑、信号翻译、窗口尺寸都由它触发,不是终端模拟器自己做的)。
  • 易混点:SSH 链路里本地和远程各有一个独立 PTY,本地那个在 ssh 客户端运行期间被切成 raw、退化成纯管道,真正的终端语义发生在远程那一层。
  • 易混点:回显、Ctrl-C 信号在 SSH 场景下都是远程产生的效果,往返了一次网络,不是本地终端自己处理的。
  • 关键区分:TTY/termios(内核层,管字节语义)vs DECSET(终端模拟器协议层,管鼠标/焦点/alt screen),两者都可能因进程被 kill -9 而残留脏状态,但清理手段不同——stty sane/reset 治 termios,发对应 DECRST 序列或 reset 治终端模拟器状态。
  • 急救命令:终端花屏时先试 reset,一次性把 termios 和常见终端模式都拉回默认。
  • 易混点docker exec -t 不是”只影响远程”。-t 的直接语义是请求容器侧分配 PTY(远程),但本地是否切 raw、监听 resize,由 -t 参数 + 本地 IsTerminal() 联合触发,本地 tty 默认是 shell 继承来的 cooked 状态,raw 化是 docker 客户端主动做的,和 ssh 客户端的行为完全同构。
  • 易混点:程序检测 stdio 类型不是”理论上可以”,而是标准库和大量常用 CLI 的默认行为——isatty() 一次判断,决定了缓冲策略(行缓冲 vs 全缓冲)、要不要输出颜色/进度条、要不要起分页器、要不要弹交互 prompt。分配 PTY(-t)的本质作用之一就是让远端程序在这个判断里得到”是”。
  • 样板代码骨架posix_openpt 开 master → grantpt+unlockpt 解锁 → ptsname 拿 slave 路径 → fork → 子进程 setsid+open(slave)+ioctl(TIOCSCTTY)+dup2 接管为控制终端 → exec 目标程序;父进程持有 master,负责读写转发与 tcsetattr/ioctl(TIOCSWINSZ) 控制。glibc 的 forkpty() 是这一整套流程的封装。

8. 下一步

  • 若需要更硬的依据,可查 POSIX termios(3) 规范原文和 Linux tty_ldisc 内核文档,替换掉当前”高置信度复述”的部分。
  • 可以翻一遍 RFC 4254 第 6.7 节,确认 window-change request 的具体字段和各实现的一致性。
  • 有兴趣可以用 strace/ltrace 实际跟踪一次 ssh 建连和 vim 启动过程,抓到真实的 tcsetattr/ioctl(TIOCSWINSZ)/写转义序列调用,把本文里的”理解”替换成实测证据。
本文由作者按照 CC BY 4.0 进行授权