我们来讨论一下伊洪大哥最近在查的 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:
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,满足了孤儿进程组。
 
 
Back to Top