在一个SSH会话里执行vi,后因TCP连接中断而失去控制。重新登录后发现原SSH会话对应的伪终端还在,其中的vi进程也在。有什么办法重新获取对vi的控制?本文通过设计一个实验复现问题并给出救急方案。另外,本文介绍了两个成熟的工具screenify、reptyr,第一次听说的朋友会感谢我的。
☆ 原始问题
在一个SSH会话里执行vi,后因TCP连接中断而失去控制。重新登录后发现原SSH会话对应的伪终端还在,其中的vi进程也在。有什么办法重新获取对vi的控制?
这种情况一般是单向TCP故障所致,即服务端没有收到FIN或RST,客户端单方面中止了TCP连接,现实中并不罕见。
1) 模拟场景
设计一个实验确保精确复现这种情况。
服务端是位于Guest中的Linux,客户端是Host中的SecureCRT。多登录几个SSH会话,其中一个SSH会话中执行”vi some.txt”。在VMware中断开虚拟网卡,在Host中用Tcpview切断vi进程所在SSH会话对应的TCP连接,由于虚拟网卡已断开,Guest中的SSH会话不会收到RST或FIN,而Host中的SecureCRT会收到。在其他SSH会话中用netstat、pstree、ps等工具确认目标SSH会话及vi进程仍在。
$ netstat -ntp | grep :22 tcp 0 52 x.x.x.x:22 y.y.y.y:1999 ESTABLISHED 1185/sshd: root@pts tcp 0 0 x.x.x.x:22 y.y.y.y:2069 ESTABLISHED 2244/sshd: scz [pri tcp 0 0 x.x.x.x:22 y.y.y.y:2070 ESTABLISHED 2342/sshd: scz [pri $ pstree -npu -al 2244 sshd,2244 `-sshd,2263,scz `-bash,2264 `-vi,2341 some.txt $ pstree -H `pidof -s vi` -npu systemd(1)-+-systemd-journal(223) ... |-sshd(651)-+-sshd(1185)-+-bash(1204) | | `-bash(2271)---pstree(2401) | |-sshd(2244)---sshd(2263,scz)---bash(2264)---vi(2341) | `-sshd(2342)---sshd(2349,scz)---bash(2350) ... [scz@ /tmp]> echo $$ 2350 [scz@ /tmp]> ps -f -o pid,user,args PID USER COMMAND 2350 scz -bash 2474 scz \_ ps -f -o pid,user,args 2264 scz -bash 2341 scz \_ vi some.txt
2) vi本身的swap机制
[scz@ /tmp]> ls -l .some.txt.swp -rw------- 1 scz scz 12288 Feb 14 11:50 .some.txt.swp
如果启动vi时没有指定-n,缺省有swap文件用于crash后的恢复。
[scz@ /tmp]> vi -r some.txt
它会自动从.some.txt.swp中恢复内容到some.txt,之后可以删除swap文件。
[scz@ /tmp]> rm .some.txt.swp
此处不考虑vi本身的这种恢复机制,考虑更普遍情形。
☆ 预防措施
1) screen
以前跑oclHashcat-plus时我就碰上过客户端单方面中止TCP连接的事,当时周大给我推荐了screen。
$ aptitude install screen
简单演示一下screen:
[scz@ /tmp]> tty /dev/pts/4 [scz@ /tmp]> screen -S screen.scz.pts_4 [scz@ /tmp]> tty /dev/pts/5 [scz@ /tmp]> vi pts_4.txt Ctrl-A D [detached from 2647.screen.scz.pts_4]
在另一个伪终端里恢复对vi的控制:
[scz@ /tmp]> tty /dev/pts/6 [scz@ /tmp]> screen -r screen.scz.pts_4
可以简单地”screen -r”、”screen -x”。看到vi界面,退出vi后检查当前伪终端:
[scz@ /tmp]> tty /dev/pts/5
退出screen状态,可以exit,也可Ctrl-D。
[screen is terminating]
2) tmux
$ aptitude install tmux
简单演示一下tmux:
[scz@ /tmp]> tty /dev/pts/4 [scz@ /tmp]> tmux [scz@ /tmp]> tty /dev/pts/7 [scz@ /tmp]> vi pts_4.txt
在另一个伪终端里夺取对vi的控制:
[scz@ /tmp]> tty /dev/pts/6 [scz@ /tmp]> tmux ls 0: 1 windows (created Thu Feb 14 13:47:38 2019) [132x57] (attached) [scz@ /tmp]> tmux attach -t 0
看到vi界面,退出vi后检查当前伪终端:
[scz@ /tmp]> tty /dev/pts/7
后果与screen类似。
[scz@ /tmp]> tmux detach [detached (from session 0)] [scz@ /tmp]> tty /dev/pts/6
退出tmux状态,可以exit,也可Ctrl-D。
[exited]
3) screen vs tmux
这是二者的比较:
http://www.wikivs.com/wiki/screen_vs_tmux
☆ 救急方案
screen、tmux要求提前考虑到风险,它们都是预防措施,非原始问题的答案。原始问题发生时,显然没有提前进入screen、tmux状态,还有救吗?
1) cryopid
A: Bernard Blackham 2004 https://github.com/maaziz/cryopid https://github.com/maaziz/cryopid.git
CryoPID允许捕捉正在运行中的进程状态并将之保存到文件中,将来利用该文件恢复进程状态,甚至可以在系统重启后或迁移至另一台主机时生效。简单理解成进程级快照,不过很怀疑它的实用性,而且没能编译成功。
[scz@ /tmp]> git clone https://github.com/maaziz/cryopid.git [scz@ /tmp/cryopid/src]> make
2) screenify(可用)
A: Timo Lindfors 2004 http://tomaw.net/tmp/screenify
脚本编写年代过早,对于较新版本GDB,需要做点小修改,下面是我改过的:
-------------------------------------------------------------------------- #!/bin/sh # Copyright Timo Lindfors 2004 function usage() { echo usage: $0 pid exit 1 } TCGETS=0x5401 TCSETS=0x5402 SIZEOF_STRUCT_TERMIOS=60 O_RDWR=2 ((FLAGS=O_RDWR)) PID=$1 if [ x`which gdb` == x ]; then echo gdb not found in PATH. Please apt-get install gdb exit fi if [ x$PID == x ]; then usage; fi if [ x$2 != x ]; then usage; fi MYPID=$$ MYFD0=`readlink /proc/$MYPID/fd/0` MYFD1=`readlink /proc/$MYPID/fd/1` MYFD2=`readlink /proc/$MYPID/fd/2` EXE=`readlink /proc/$PID/exe` if [ x$EXE == x ]; then echo $0: $PID: no such pid exit 1 fi BATCHFILE=`mktemp -p /tmp "gdb.$$_${RANDOM}_XXXXXXXXXX"` cat >$BATCHFILE <<EOF file $EXE attach $PID call (char*)malloc($SIZEOF_STRUCT_TERMIOS) call (char*)malloc($SIZEOF_STRUCT_TERMIOS) call (char*)malloc($SIZEOF_STRUCT_TERMIOS) call (void)ioctl(0, $TCGETS, \$1) call (void)ioctl(1, $TCGETS, \$2) call (void)ioctl(2, $TCGETS, \$3) call (void)close(0) call (void)close(1) call (void)close(2) call (int)open("$MYFD0", $FLAGS) call (int)open("$MYFD1", $FLAGS) call (int)open("$MYFD2", $FLAGS) call (void)ioctl(0, $TCSETS, \$1) call (void)ioctl(1, $TCSETS, \$2) call (void)ioctl(2, $TCSETS, \$3) call (void)free(\$1) call (void)free(\$2) call (void)free(\$3) detach EOF gdb -batch -x $BATCHFILE >/dev/null 2>&1 </dev/null rm $BATCHFILE cat <<EOF Process $PID should now be talking to this pty. Refresh the screen (e.g. ESC CTRL+L) and have fun! EOF exec tail -f --pid=$PID /proc/$PID/stat --------------------------------------------------------------------------
GDB调试目标进程;用TCGETS取stdin、stdout、stderr的终端属性,这个应该就是tcgetattr();关闭原来的0/1/2号句柄;分三次打开当前SHELL所在伪终端,正常情况下它们的句柄依次是0/1/2;用TCSETS重设stdin、stdout、stderr的终端属性,这个应该就是tcsetattr();至此,目标进程的stdin、stdout、stderr转移到当前SHELL所在伪终端。
参看ioctl_tty(2)。
脚本中的三次open()是在目标进程空间中执行的,受制于目标进程的权限,如果当前SHELL与目标进程均属于同一用户,不存在问题。假设目标进程属于scz,当前SHELL属于root,open()会失败(返回-1),这是个小坑。
这种只依赖GDB的技术方案有一些限制,参看:
《2.12 在GDB中调用被调试进程空间中的函数》
《2.19 在GDB里如何搜索内存》
《2.31 GDB中如何调用指定动态链接库中的导出函数》
《2.62 GDB条件断点中进行字符串比较》
[scz@ /tmp]> tty /dev/pts/3 [scz@ /tmp]> vi some.txt
随便编辑点内容,然后切到另一个伪终端:
[scz@ /tmp]> tty /dev/pts/4 [scz@ /tmp]> ps -f -o pid,user,args PID USER COMMAND 3599 scz -bash 3608 scz \_ ps -f -o pid,user,args 3590 scz -bash 3606 scz \_ vi some.txt
夺取对vi的控制:
[scz@ /tmp]> ./screenify 3606
按ESC之后再Ctrl-L刷新屏幕,看到vi界面,可以正常操作并保存退出。此时/dev/pts/3上的bash已经无法工作,但vi正常退出,之前编辑的内容得以保存。
3) retty
A: Petr Baudis, Jan Sembera 2006
retty attach processes running on other terminals
http://pasky.or.cz//dev/retty/
http://pasky.or.cz//dev/retty/retty-1.0.tar.bz2
retty本质上同screenify,我未实测。
4) injcode
A: Thomas Habets 2009-03-21
Moving a process to another terminal
https://blog.habets.se/2009/03/Moving-a-process-to-another-terminal.html
http://github.com/ThomasHabets/injcode
git clone git://github.com/ThomasHabets/injcode.git
2009年之后再未更新。
5) neercs
A: Sam Hocevar, Jean-Yves Lamoureux, Pascal Terjan
http://caca.zoy.org/wiki/neercs
http://caca.zoy.org/wiki/neercs?format=txt
http://caca.zoy.org/wiki/neercs/devel
git://git.zoy.org/neercs.git
http://caca.zoy.org/git/neercs.git
neercs比较复杂,我未实测。
6) reptyr(推荐)
A: Nelson Elhage 2011
reptyr: Attach a running process to a new terminal – [2011-01-21]
https://blog.nelhage.com/2011/01/reptyr-attach-a-running-process-to-a-new-terminal/
http://github.com/nelhage/reptyr
https://github.com/nelhage/reptyr.git
reptyr: Changing a process’s controlling terminal – [2011-02-08]
https://blog.nelhage.com/2011/02/changing-ctty/
(介绍reptyr修改目标进程控制终端的技术原理)
New reptyr feature: TTY-stealing – [2014-08-20]
https://blog.nelhage.com/2014/08/new-reptyr-feature-tty-stealing/
Reptyr Move A Running Process From One Terminal To Another Without Closing It – [2017-02-26]
https://www.ostechnix.com/reptyr-move-running-process-new-terminal/
对less使用screenify,less仍从旧终端读取输入。对ncurses程序使用screenify,无法调整窗口大小。对程序使用screenify,新终端上Ctrl-C无效。reptyr解决了这些问题。
reptyr可以在i386、x86_64、ARM上运行。
常见使用方式:
a) reconnect ssh b) screen c) ps -a | grep <orphaned process> d) reptyr <pid>
Debian有这个包,说明reptyr已成为业界通用工具。万一发行版不带reptyr,就自己编译源码吧。
$ aptitude install reptyr
6.1) reptyr(1)
NAME reptyr - 给正在运行中的目标进程更换控制终端(CTTY) SYNOPSIS reptyr PID reptyr -l|-L [COMMAND [ARGS]] OPTIONS
-T
reptyr不是用ptrace(2)调试目标进程,而是试图找出目标进程对应的terminal emulator并劫持mater pty。这种模式更可靠,适用性更强。此时可以更改目标进程所在session的所有进程的CTTY。缺点是,除非以root身份执行reptyr,否则不能用于sshd(8)的子进程。
-l, -L [COMMAND [ARGS]]
此时没有目标进程。这种模式将创建新的pty对,在新master pty与当前终端之间进行数据转发,显示新slave pty的名字(/dev/pts/N),新slave pty没有进程与之关联。假设正用gdb调试某进程,新slave pty可做为”set inferior-tty”的参数,这比被调试进程直接使用当前终端要好。
(gdb) help set inferior-tty
Set terminal for future runs of program being debugged.
Usage: set inferior-tty [TTY]
If TTY is omitted, the default behavior of using the same terminal as GDB is restored.
如果指定了COMMAND、ARGS,将做为reptyr的子进程运行,其进程空间环境变量REPTYR_PTY指向新slave pty。-L相比-l,前者会将子进程的0、1、2号fd指向新slave pty,子进程会在一个新session中运行,其CTTY对应新slave pty。
-s
缺省情况下,reptyr只会让目标进程中确实与CTTY相关联的fd指向新终端。指定 -s后,reptyr死活将目标进程中的0、1、2号fd指向新终端,即使目标进程本来没有CTTY。
一般情况下用不着-s,用reptyr时,目标进程很大可能是交互式进程。
-v
显示版本
-h
显示帮助
-V
输出冗余调试信息
NOTES
reptyr使用ptrace(2)调试目标进程。在Ubuntu Maverick及更高版本上,出于安全考虑缺省禁止这种行为。可以临时解禁:
# echo 0 > /proc/sys/kernel/yama/ptrace_scope
也可以编辑/etc/sysctl.d/10-ptrace.conf永久解禁。
BUGS
如果目标进程的屏幕未能重绘,按Ctrl-L
假设目标进程对stdin使用epoll(),reptyr并未更新epoll()所用数据,epoll()仍将访问原来的stdin。
6.2) reptyr原理简介
reptyr用ptrace(2)调试目标进程(vi),利用一些Hacking技术在vi进程空间里执行由reptyr提供的代码,比如打开新的伪终端,利用dup(2)使之变成vi进程的stdout、stderr。相比screenify,reptyr更改了vi进程的控制终端(CTTY),于是支持对目标进程Ctrl-C、Ctrl-Z。同一session中的所有进程共用同一个CTTY。
参看ioctl_tty(2)
TIOCSCTTY int arg
修改主调进程的CTTY。主调进程必须是session leader,同时不能已经拥有CTTY。此时这样调用:
ioctl( fd, TIOCSCTTY, 0 )
如果fd已经是某个session的CTTY,ioctl()失败(EPERM),除非主调进程拥有CAP_SYS_ADMIN权限且arg等于1,此时会抢夺CTTY,原session中所有进程将失去fd对应的CTTY。此时这样调用:
ioctl( fd, TIOCSCTTY, 1 )
从bash中启动vi,bash是session leader,vi是process group leader,该进程组只包含vi进程。为了在vi中调用ioctl(TIOCSCTTY),须设法让vi成为session leader。
参看setsid(2)
EPERM
主调进程PID等于某个PGID,即主调进程是process group leader时,setsid()失败。
vi现在是process group leader,无法调用setsid(2)。可以fork(),子进程仍在同一session、同一进程组,但不是process group leader,该子进程可以setsid()。但fork()后杀掉父进程的做法有潜在风险,谁知道vi有没有依赖PID的行为。看有无其他办法更改vi的PGID,使得vi不再是process group leader。
参看setpgid(2)
setpgid( pid, pgid );
bash处理管道符时会用setpgid()将指定进程移入指定进程组。这个操作要求pgid与pid位于同一session(参看setsid(2)、credentials(7))。
需要在vi所在session中找一个进程组,把vi移入该进程组,使得vi可以调用setsid()。bash似乎是个候选者,但我们采用更直接的办法,创建一个新进程组。在vi进程空间中fork(2),同时用ptrace(2)调试子进程。让子进程调用setpgid()创建新进程组,将父进程移入该新进程组,父进程中的vi可以调用setsid()创建新session,父进程成为session leader,父进程调用ioctl(TIOCSCTTY)指定新的CTTY。
injcode、neercs、reptyr使用同样的技术更改目标进程的CTTY。
6.3) “reptyr -T”原理简介
“reptyr -T”使用了新技术,劫持目标进程关联的master pty。
不使用-T时,reptyr更改单个目标进程的slave pty。使用-T时,reptyr尝试寻找目标进程对应的terminal emulator,用ptrace(2)调试后者(而不是目标进程),寻找master pty fd,利用AF_UNIX、SCM_RIGHTS将master pty fd传递到reptyr进程。reptyr在terminal emulator进程空间中更改master pty fd,使之指向/dev/null,最后从terminal emulator detach。
接着reptyr扮演terminal emulator的角色,从前述master pty fd读取output并写到当前终端,从当前终端读取input并写到前述master pty fd。
假设terminal emulator是sshd(8)的子进程,sshd会调用setuid(2)以匹配登录帐号。Linux禁止ptrace(2)调试这种调用过setuid(2)的进程,除非以root身份执行reptyr。
☆ 伪终端
单说解决原始问题,不需要深入了解伪终端内部细节,如果充满好奇心,可以继续阅读如下四个链接:
A Brief Introduction to termios – [2009-12-22]
https://blog.nelhage.com/2009/12/a-brief-introduction-to-termios/
A Brief Introduction to termios: termios(3) and stty – [2009-12-30]
https://blog.nelhage.com/2009/12/a-brief-introduction-to-termios-termios3-and-stty/
A Brief Introduction to termios: Signaling and Job Control – [2010-01-11]
https://blog.nelhage.com/2010/01/a-brief-introduction-to-termios-signaling-and-job-control/
The TTY demystified – [2008-07-25]
http://www.linusakesson.net/programming/tty/index.php
(介绍TTY的历史及架构变迁,比如PTY如何出现)
简译了对我本人有用的部分内容,可能有错误。
1) pty(7)/tty(4)
-----------input--------------> +--------+ +--------+ +-------+ +--------+ +--------+ |terminal|=| master |=|termios|=| slave |=|shell or| |emulator| | pty | | | | pty | |other(s)| +--------+ +--------+ +-------+ +--------+ +--------+ <----------output-------------- xterm /dev/ptmx /dev/pts/N bash sshd screen tmux gnome-terminal termios负责: Line buffering 行缓冲 Echo 回显 Line editing 退格删除 Newline translation \n转\r\n Signal generation Ctrl-C SIGINT Ctrl-Z SIGTSTP
右侧允许多个进程连接同一个slave pty,不一定是bash。
2) termios(3)
emacs、vi之类的程序使用了curses。可以通过struct termios调整termios的行为。
tcflag_t c_iflag; /* input modes */ tcflag_t c_oflag; /* output modes */ tcflag_t c_cflag; /* control modes */ tcflag_t c_lflag; /* local modes */ cc_t c_cc[NCCS]; /* control chars */ local modes ICANON canonical mode就是line editing mode,与之对应的是cbreak mode(raw mode) ECHO 回显 ISIG 若未设置,Ctrl-C、Ctrl-Z不会产生信号,而是向右侧传递相应ASCII码 TOSTOP 参后 input and output modes IXON in c_iflag
是否允许流控。缺省启用,master pty收到Ctrl-S后,slave pty不再从右侧接收任何输出,向slave pty的write()操作将阻塞,直到master pty收到Ctrl-Q,恢复正常。
IUTF8 in c_iflag
IUTF8告诉termios输入流是UTF-8编码过的,处理退格删除时以单个UTF-8字符为单位进行删除。
OLCUC in c_oflag
Map[s] lowercase characters to uppercase on output
control chars
下列c_cc[i]为0时表示禁用 VINTR c_cc[VINTR] = 0x3; Ctrl-C产生SIGINT,要求ISIG置位 VSUSP Ctrl-Z产生SIGTSTP,要求ISIG置位 VERASE Ctrl-H或Ctrl-?退格删除 VEOF Ctrl-D causes the next read call by the slave to return end-of-file. VSTOP Ctrl-S,要求IXON置位 VSTART Ctrl-Q,要求IXON置位
第一次接触这些东西是1998年,当时进行curses编程,与西门子某设备进行串口通信。
3) stty(1)
stty封装了对tcgetattr()、tcsetattr()的调用。
stty -a
以人类可读方式显示struct termios
stty -isig
复位ISIG,此时无法Ctrl-C中止进程。
stty intr ^G
c_cc[VINTR] = 0x7;
Ctlr-G产生SIGINT
stty -ixon stop undef
复位IXON
c_cc[VSTOP] = 0;
stty -a -F /dev/pts/N
查看指定伪终端
bash有自己的termios设置,从bash中启动其他进程时,bash会将控制终端的termios设置恢复回去。所以在bash中直接stty与从其他终端stty -F看到的不一致。
4) ioctl(2)/ioctl_tty(2)
tcgetattr( fd, p ) ioctl( fd, TCGETS, p ) tcsetattr( fd, p ) ioctl( fd, TCSETS, p )
5) Sessions and Process Groups
session leader (SID==PID) setsid(2) process group leader (PGID==PID) setpgid(2) process process ... process group leader process process ... ... [scz@ /tmp]> cat /dev/urandom > /dev/null # cat /proc/$(pidof -s cat)/stat 7559 (cat) R 5487 7559 5487 ... 前6项是 pid (name) state ppid pgid sid # pstree -npu -al 5487 bash,5487,scz └─cat,7559 /dev/urandom # ps -o pid,args,state,ppid,pgid,sid $(pidof -s cat) PID COMMAND S PPID PGID SID 7559 cat /dev/urandom R 5487 7559 5487
每个session有一个控制终端(CTTY)。单个进程可以打开多个终端,但只有CTTY可以进行任务控制(job control),比如Ctrl-Z。一个终端最多只能成为一个session的CTTY。某进程调用setsid(2)创建新session的同时,会失去原有的CTTY。若某进程没
有CTTY,当它不带O_NOCTTY标志打开某终端时,该终端自动成为其CTTY。
每个CTTY只有一个前台进程组,同一session中的其他进程属于后台进程组。
CTTY产生的控制信号不是发往单个进程,而是发往前台进程组(中的所有进程)。
前台进程组可以任意读写CTTY,可以对CTTY调用tcsetattr()。
后台进程组中的进程试图读CTTY时,该后台进程组将收到SIGTTIN。后台进程组中的进程可以写CTTY,除非c_lflag中TOSTOP置位,此时该后台进程组将收到SIGTTOU。后台进程组中的进程对CTTY调用tcsetattr()时,该后台进程组将收到SIGTTOU。
session中的进程可以调用tcsetpgrp()设置前台进程组,所受限制同前述tcsetattr()。
关于SIGHUP,参看:
《24.3 如何编写daemon程序》
一般来说,有两种典型的与SIGHUP信号相关的情形。
假设某session有控制终端,当session leader终止时,系统会向该session前台进程组中所有进程及后台进程组中处于”停止”状态的每个进程分发SIGHUP信号。
如果某进程组中有一个进程,其父进程属于同一会话(session)的另一个进程组,则该进程组不是”孤儿进程组”,反之该进程组称为”孤儿进程组”。
APUE 9.10指出,当某进程的终止导致一个新的”孤儿进程组”产生,系统会向这个新的”孤儿进程组”中处于”停止”状态的每个进程分发SIGHUP信号,然后分发SIGCONT信号。那些未处于”停止”状态的进程不会收到这两个信号。
6) Job control
任务就是进程组的别称。bash有个内部命令jobs,”help jobs”了解细节。
假设在bash中执行”foo | bar | grep baz”,bash会调用setpgid(),把这三个进程置于同一进程组,接着调用tcsetpgrp()使之成为前台进程组,最后调用waitpid()。此时Ctrl-C会杀死所有三个进程。按下Ctrl-Z,这三个进程都会被挂起,bash对waitpid()的调用将返回,bash恢复自己成前台进程组,这三个进程所在进程组变成后台进程组。
在bash中使用”bg %n”命令时,bash调用killpg(2)向指定后台进程组发送SIGCONT。后台进程组试图读CTTY时,会收到SIGTTIN,bash的wait*()会监控到后台进程组的状态变化。
在bash中使用”fg %n”命令时,bash调用tcsetpgrp()将指定进程组变成前台进程组。
7) 信号与任务控制示例
假设你正在用emacs编辑大文件,光标位于屏幕中部某处,此时emacs正对该文件进行搜索、替换操作。按下Ctrl-Z,emacs所在前台进程组收到SIGTSTP。
emacs的SIGTSTP信号句柄得到执行,通过向CTTY写入相应控制序列移动光标至屏幕最后一行。接着emacs向自己所在前台进程组发送SIGSTOP。
emacs现在被挂起,session leader收到SIGCHLD,知道emacs状态发生变化。当前台进程组中所有进程被挂起后,session leader保存当前termios设置,以备将来恢复用。session leader调用tcsetpgrp()将自身所在进程组设置成前台进程组,输出形如”[1]+ Stopped”的信息,通知用户有任务被挂起。
ps(1)可以看到emacs处在停止状态(T)。可以用bash内置命令bg使emacs继续执行,也可以用kill(1)向emacs发送SIGCONT,emacs的SIGCONT信号句柄得到执行,这将试图重绘emacs的GUI。但是,emacs现在位于后台进程组,写CTTY导致emacs收到SIGTTOU,emacs再次停止运行,session leader再次收到SIGCHLD,再次输出”[1]+ Stopped”。
用bash内置命令fg,bash恢复之前保存的termios设置,调用tcsetpgrp()将emacs所在进程组设置成前台进程组,向前台进程组发送SIGCONT。emacs的SIGCONT信号句柄得到执行,重绘emacs的GUI。
8) setsid(1)
这是个外部命令
$ dpkg -S $(which setsid)
util-linux: /usr/bin/setsid
————————————————————————–
NAME
setsid – 在新session中运行指定程序
SYNOPSIS
setsid [options] program [arguments]
DESCRIPTION
setsid在一个新session中执行指定程序。
如果setsid本身已经是process group leader,会调用fork(2)创建子进程,在子进程中调用setsid(2),否则直接调用setsid(2);最后调用exec*()执行指定程序。如果使用–fork参数,则总是创建子进程。考虑setsid不从bash启动,而是由其他进程或脚本启动。
OPTIONS
-c, –ctty
将指定程序的CTTY设置成当前终端
一个终端最多只能成为一个session的CTTY,ioctl(TIOCSCTTY)会抢夺CTTY。
-f, –fork
总是创建新(子)进程
-w, –wait
等待指定程序执行结束,其退出码做为setsid命令的退出码
-V, –version
显示版本
-h, –help
显示帮助
SEE ALSO
setsid(2)
$ strace -f -ff -o /tmp/setsid.log setsid cat /tmp/some.txt scz@nsfocus $ ls -l /tmp/setsid.log* -rw-r--r-- 1 scz scz 5755 Feb 19 11:21 /tmp/setsid.log.8526 $ vi /tmp/setsid.log.8526 ... getpgrp() = 8523 getpid() = 8526 setsid() = 8526 execve("/bin/cat", ["cat", "/tmp/some.txt"], 0xbfa0c2b4 /* 20 vars */) = 0 ... strace的情形下setsid已经是在fork(2)后的子进程中运行,此时setsid不是process group leader,可以直接调用setsid(2)。 $ rm /tmp/setsid.log* $ strace -f -ff -o /tmp/setsid.log setsid -f cat /tmp/some.txt $ ls -l /tmp/setsid.log* -rw-r--r-- 1 scz scz 2654 Feb 19 11:32 /tmp/setsid.log.8549 -rw-r--r-- 1 scz scz 3274 Feb 19 11:32 /tmp/setsid.log.8550 $ vi /tmp/setsid.log.8549 ... clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0xb7f0a168) = 8550 ... $ vi /tmp/setsid.log.8550 setsid() = 8550 execve("/bin/cat", ["cat", "/tmp/some.txt"], 0xbfb9f428 /* 20 vars */) = 0 ...
“setsid -f”时死活fork(),现在libc中的fork(3)由clone(2)实现,尽管fork(2)仍然可用。setsid.log.8549中没有getpgrp()、getpid(),因为-f参数不需要检查setsid是否是process group leader。
9) jobs/disown/nohup
$ help jobs jobs: jobs [-lnprs] [jobspec ...] or jobs -x command [args] Display status of jobs. Lists the active jobs. JOBSPEC restricts output to that job. Without options, the status of all active jobs is displayed. Options: -l lists process IDs in addition to the normal information -n lists only processes that have changed status since the last notification -p lists process IDs only -r restrict output to running jobs -s restrict output to stopped jobs If -x is supplied, COMMAND is run after all job specifications that appear in ARGS have been replaced with the process ID of that job's process group leader. Exit Status: Returns success unless an invalid option is given or an error occurs. If -x is used, returns the exit status of COMMAND. $ help disown disown: disown [-h] [-ar] [jobspec ... | pid ...] Remove jobs from current shell. Removes each JOBSPEC argument from the table of active jobs. Without any JOBSPECs, the shell uses its notion of the current job. Options: -a remove all jobs if JOBSPEC is not supplied -h mark each JOBSPEC so that SIGHUP is not sent to the job if the shell receives a SIGHUP -r remove only running jobs Exit Status: Returns success unless an invalid option or JOBSPEC is given. $ type disown disown is a shell builtin
bash(1)关于SIGHUP的内容不清晰,下面加以补充。
交互式(无论登录、非登录)bash收到SIGHUP会退出,在其结束前会向所有前后台任务(无论状态)发SIGHUP。假设此时有停止状态的后台任务,交互式bash可能还会向其发送SIGCONT,但我从未观察到过。
交互式(无论登录、非登录)bash主动exit时,不会向运行状态的后台任务发送任何信号,会向停止状态的后台任务发送SIGTERM,不是SIGHUP。
$ shopt -s huponexit $ shopt | grep hup huponexit on
假设huponexit被启用(缺省是off),交互式登录bash在退出前(无论是收到SIGHUP还是主动exit)会向所有前后台任务发送SIGHUP,包括运行状态的后台任务。huponexit只影响交互式登录bash,不影响交互式非登录bash。比如SSH登录后的bash受huponexit影响,在登录bash中新启动的bash不受huponexit影响。这些结论可以用strace确认。
“disown %n”是bash内置命令,其作用是将后台任务从bash的任务列表中移除,之后无法对被移除的后台任务使用fg、bg命令,同时阻止交互式bash收到SIGHUP之后向被移除任务发送SIGHUP。
“disown -h”只对付SIGHUP,不从任务列表中移除指定任务,此时可以fg、bg。
disown不会影响PID、PPID、PGID、SID,也不剥离CTTY。
若某停止状态的后台任务事先被disown过,收到SIGHUP的交互式bash确实不会向之发送SIGHUP,但是kernel会向之发送SIGHUP,因为此时有新的”孤儿进程组”产生。这意味着disown过的停止状态的后台任务仍将被杀,strace可能看到:
--- stopped by SIGTSTP --- --- SIGHUP {si_signo=SIGHUP, si_code=SI_KERNEL} --- +++ killed by SIGHUP +++
对运行状态的任务(无论前后台)使用disown才稍有意义,disown其实十分鸡肋。
参看:
Difference between nohup, disown and & – [2010-11-09]
https://unix.stackexchange.com/questions/3886/difference-between-nohup-disown-and
对比&、nohup、disown的使用,不过有些内容术语混乱、表述不严谨,请自行修正。
nohup启动进程之前重定向stdout、对SIGHUP使用SIG_IGN(被启动进程可能更改这种设置)。nohup并不影响session、CTTY。
&只是将进程丢入bash后台运行,不影响stdin、stdout、stderr、CTTY。
☆ 结束语
原始问题在过去经常碰上,始终没有深究过救急方案,只是从原理上知道动用llkm、tty hijacking技术可能可能解决,因为以前搞过tty hijacking。但涉及内核态编程,稳定性、可移植性不高。
最近有同事找过来问这个事,当时想当然地以为没有成熟工具,谁知道在用户态有screenify、reptyr这两种成熟工具,尤其后者进了Debian发行版。
20年前搞过curses编程,当时仅限于利用各种API实现功能,没有从更深处理解伪终端机制,这次一并学习了一点点。本文没有原创技术点,汇总从2004年至今在互联网上能找到的关于此问题的绝大多数有价值的讨论。本文价值在于将这些零散分布的有用信息归档一处。建议直接阅读英文原文,我是反复过了好几遍。
那些第一次听说screenify、reptyr的人,你们会感谢我的。