我们来讨论一下伊洪大哥最近在查的 cpython bug https://github.com/python/cpython/issues/135329
TLDR 不要看里面 j 某人的回复,是错的
我们从头开始。
strace ./python 然后 ctrl-c 发现 python 进程无限报错循环。 strace 很复杂,先想办法剥离关键因素,试试单独跑 ./python 然后 strace -fTp $(pidof python) 再 ctrl-c,嘿不报错!太好了说明不是 ptrace 引发的错误,而是进程管理、终端、信号相关的问题。太好了,不然我又要去看一遍 signal-delivery-stop...
那就手动来一层套娃。已知 shell -> strace -> python repl 的进程树有问题,但是 strace ptrace 又并非关键,那就把 strace 换掉试试看: shell -> python -> python repl:
再 ctrl-c,居然也可以复现!
那就好办了,strace -fTttp $(pidof python) &>crash.strace.log 启动!
ioctl 返回 EIO 循环了。
继续剥离,用 strace 注入 syscall error:
此时的 python 进程不需要套娃,shell 直起也能 ctrl-c 报错。
所以可以确定 cpython bug 了:cpython 无法正确 EIO 报错,一旦 EIO 就触发 cpython 做栈回溯 + ioctl,进而触发新一轮的 EIO,无限循环。注意这是 python 3.13 new repl 的功能,所以也解释了为什么旧版 cpython repl 不会陷入此报错循环。
以上你并不需要知道 OS 的细节就可以追查到,希望大家都可以反复练习熟练掌握。
下面进入内核,为什么内核返回 EIO?因为伊洪要写测试,所以在容器内正确地创造出 EIO 还是很重要的。
retsnoop 抓一波
去看内核 __tty_check_change 的源码,里面只有两处 return -EIO
所以是进程收到 SIGTTOU 时候,如果 TTOU 没有被 ignore,而进程所在的进程组是孤儿(注意:孤儿进程组 != 孤儿进程),则返回 EIO。
SIGTTOU 的触发条件是后台进程写控制终端,所以这里要求的进程结构至少应该是 p1 (1号进程,创建 session 和 pty) -> p2 (repl), p1 设置自己为前台进程组,设置 p2 repl 为后台进程组,此时 repl IO 触发 TTOU。
孤儿进程组要麻烦一点,我们先考虑最简单的三层进程树: p1 (1号进程,创建 session pty) -> p2 (独立进程组,exit immediately) -> p3 (repl),由于 p2 退出,p3 孤儿被 re-parent 到 pid 1 即 p1,此时由于 p1, p3 依然在一个 session,p3 并非处在孤儿进程组。
因此要考虑一个复杂的四层进程树: p1 (1号,不做事) -> p2 (创建 session pty) -> p3 (独立进程组,exit) -> p4 (repl)。此时 p4 会被 reparent 到 p1 ,所以 p4 所在进程组的全部进程(只有 p3 p4 但 p3 无了)的父进程们都和进程组的父进程们不在同一个 session,满足了孤儿进程组。
TLDR 不要看里面 j 某人的回复,是错的

我们从头开始。
strace ./python 然后 ctrl-c 发现 python 进程无限报错循环。 strace 很复杂,先想办法剥离关键因素,试试单独跑 ./python 然后 strace -fTp $(pidof python) 再 ctrl-c,嘿不报错!太好了说明不是 ptrace 引发的错误,而是进程管理、终端、信号相关的问题。太好了,不然我又要去看一遍 signal-delivery-stop...
那就手动来一层套娃。已知 shell -> strace -> python repl 的进程树有问题,但是 strace ptrace 又并非关键,那就把 strace 换掉试试看: shell -> python -> python repl:
pid = os.fork()
if pid == 0:
exe = sys.executable
os.execlp(exe, os.path.basename(exe))
os._exit(127)
os.waitpid(pid, 0)再 ctrl-c,居然也可以复现!
那就好办了,strace -fTttp $(pidof python) &>crash.strace.log 启动!
20:18:26 --- SIGINT {si_signo=SIGINT, si_code=SI_USER, si_pid=4678, si_uid=1000} ---
20:18:26 rt_sigreturn({mask=[]}) = -1 EINTR (Interrupted system call) <0.000007>
20:18:26 write(4, "\33[?2004l", 8) = 8 <0.000013>
20:18:26 write(4, "\33[?1l\33>", 7) = 7 <0.000012>
20:18:26 ioctl(3, TCGETS, {c_iflag=IXON|IUTF8, c_oflag=NL0|CR0|TAB0|BS0|VT0|FF0|OPOST|ONLCR, c_cflag=B38400|CS8|CREAD, c_lflag=ISIG|ECHOE|ECHOK|IEXTEN|ECHOCTL|ECHOKE, ...}) = 0 <0.000008>
20:18:26 ioctl(3, TCGETS, {c_iflag=IXON|IUTF8, c_oflag=NL0|CR0|TAB0|BS0|VT0|FF0|OPOST|ONLCR, c_cflag=B38400|CS8|CREAD, c_lflag=ISIG|ECHOE|ECHOK|IEXTEN|ECHOCTL|ECHOKE, ...}) = 0 <0.000006>
20:18:26 ioctl(3, TCSETSW, {c_iflag=ICRNL|IXON|IUTF8, c_oflag=NL0|CR0|TAB0|BS0|VT0|FF0|OPOST|ONLCR, c_cflag=B38400|CS8|CREAD, c_lflag=ISIG|ICANON|ECHO|ECHOE|ECHOK|IEXTEN|ECHOCTL|ECHOKE, ...}) = -1 EIO (Input/output error) <0.000007>
...
20:18:26 ioctl(3, TCSETSW, {c_iflag=BRKINT|IUTF8, c_oflag=NL0|CR0|TAB0|BS0|VT0|FF0|ONLCR, c_cflag=B38400|CS8|CREAD, c_lflag=ISIG|ECHOE|ECHOK|ECHOCTL|ECHOKE, ...}) = -1 EIO (Input/output error) <0.000008>
...
20:18:26 ioctl(3, TCSETSW, {c_iflag=BRKINT|IUTF8, c_oflag=NL0|CR0|TAB0|BS0|VT0|FF0|ONLCR, c_cflag=B38400|CS8|CREAD, c_lflag=ISIG|ECHOE|ECHOK|ECHOCTL|ECHOKE, ...}) = -1 EIO (Input/output error) <0.000008>ioctl 返回 EIO 循环了。
继续剥离,用 strace 注入 syscall error:
sudo strace -e inject=ioctl:error=5 -p $(pidof python)此时的 python 进程不需要套娃,shell 直起也能 ctrl-c 报错。
所以可以确定 cpython bug 了:cpython 无法正确 EIO 报错,一旦 EIO 就触发 cpython 做栈回溯 + ioctl,进而触发新一轮的 EIO,无限循环。注意这是 python 3.13 new repl 的功能,所以也解释了为什么旧版 cpython repl 不会陷入此报错循环。
以上你并不需要知道 OS 的细节就可以追查到,希望大家都可以反复练习熟练掌握。
下面进入内核,为什么内核返回 EIO?因为伊洪要写测试,所以在容器内正确地创造出 EIO 还是很重要的。
retsnoop 抓一波
# sudo ./retsnoop -x EIO -a '*tty*' -e '*tty*'
10:10:44.178847 -> 10:10:44.178850 TID/PID 30657/30657 (python/python):
entry_SYSCALL_64_after_hwframe+0x76
do_syscall_64+0x7e
x64_sys_call+0x131e
__x64_sys_ioctl+0xa4
2us [-EIO] tty_ioctl+0x515
⌇ 1us [-EIO] n_tty_ioctl+0x85
⌇ 1us [-EIO] n_tty_ioctl_helper+0x2d
⌇ 0us [-EIO] tty_mode_ioctl+0x1c1
set_termios+0x5d
⌇ 0us [-EIO] tty_check_change+0x13
!⌇ 0us [-EIO] __tty_check_change 去看内核 __tty_check_change 的源码,里面只有两处 return -EIO
int tty_check_change(struct tty_struct *tty)
{
return __tty_check_change(tty, SIGTTOU);
}
int __tty_check_change(struct tty_struct *tty, int sig)
{
if (tty_pgrp && pgrp != tty_pgrp) {
if (is_ignored(sig)) {
if (sig == SIGTTIN)
ret = -EIO;
} else if (is_current_pgrp_orphaned())
ret = -EIO;所以是进程收到 SIGTTOU 时候,如果 TTOU 没有被 ignore,而进程所在的进程组是孤儿(注意:孤儿进程组 != 孤儿进程),则返回 EIO。
SIGTTOU 的触发条件是后台进程写控制终端,所以这里要求的进程结构至少应该是 p1 (1号进程,创建 session 和 pty) -> p2 (repl), p1 设置自己为前台进程组,设置 p2 repl 为后台进程组,此时 repl IO 触发 TTOU。
孤儿进程组要麻烦一点,我们先考虑最简单的三层进程树: p1 (1号进程,创建 session pty) -> p2 (独立进程组,exit immediately) -> p3 (repl),由于 p2 退出,p3 孤儿被 re-parent 到 pid 1 即 p1,此时由于 p1, p3 依然在一个 session,p3 并非处在孤儿进程组。
因此要考虑一个复杂的四层进程树: p1 (1号,不做事) -> p2 (创建 session pty) -> p3 (独立进程组,exit) -> p4 (repl)。此时 p4 会被 reparent 到 p1 ,所以 p4 所在进程组的全部进程(只有 p3 p4 但 p3 无了)的父进程们都和进程组的父进程们不在同一个 session,满足了孤儿进程组。