正如@Bakuriu在评论中指出的那样,这与 BASH 中的问题基本相同: 输入中断电流终端期间的 Ctrl+C 但是,我只能在 bash 作为另一个可执行文件的子进程运行时重现问题,而不是直接从 bash 中运行,它似乎可以很好地处理终端清理。 我很想知道为什么bash在这方面似乎被打破了。
我有一个 Python 脚本,用于记录由该脚本启动的子进程的输出。 如果子进程恰好是一个 bash 脚本,它在某个时候通过调用内置的 read -s
来读取用户输入(-s
,它防止输入字符的回显,是键),并且用户中断脚本(即通过 Ctrl-C),则 bash 无法将输出恢复到 tty,即使它继续接受输入。
我把它缩减为一个简单的例子:
$ cat test.py
#!/usr/bin/python
import subprocess as sp
p = sp.Popen(['bash', '-c', 'read -s foo; echo $foo'])
p.wait()
运行./test.py
后,它将等待一些输入。 如果键入一些输入并按 Enter 键,脚本将按预期返回并回显输入,并且没有问题。 但是,如果您立即点击"Ctrl-C",Python 会显示KeyboardInterrupt
的回溯,然后返回到 bash 提示符。 但是,您键入的任何内容都不会显示在终端上。 但是,键入reset<enter>
成功重置终端。
我对这里到底发生了什么有些茫然。
更新:我也设法在没有 Python 的情况下重现了这一点。 我试图在strace中运行bash,看看我是否可以收集到正在发生的事情。 使用以下 bash 脚本:
$ cat read.sh
#!/bin/bash
read -s foo
echo $foo
运行strace ./read.sh
并立即按 Ctrl-C 会产生:
...
ioctl(0, SNDCTL_TMR_TIMEBASE or SNDRV_TIMER_IOCTL_NEXT_DEVICE or TCGETS, {B38400 opost isig icanon -echo ...}) = 0
brk(0x1a93000) = 0x1a93000
read(0, Process 25487 detached
<detached ...>
其中 PID 25487 read.sh
. 这使终端处于相同的损坏状态。 但是,strace -I1 ./read.sh
只是中断./read.sh
过程并返回到正常的、未损坏的终端。
这似乎与bash -c
启动非交互式 shell 的事实有关。这可能会阻止它恢复终端状态。
要显式启动交互式 shell,您只需将 -i
选项传递给 bash。
$ cat test_read.py
#!/usr/bin/python3
from subprocess import Popen
p = Popen(['bash', '-c', 'read -s foo; echo $foo'])
p.wait()
$ diff test_read.py test_read_i.py
3c3
< p = Popen(['bash', '-c', 'read -s foo; echo $foo'])
---
> p = Popen(['bash', '-ic', 'read -s foo; echo $foo'])
当我运行并按 Ctrl+C 时:
$ ./test_read.py
我获得:
Traceback (most recent call last):
File "./test_read.py", line 4, in <module>
p.wait()
File "/usr/lib/python3.5/subprocess.py", line 1648, in wait
(pid, sts) = self._try_wait(0)
File "/usr/lib/python3.5/subprocess.py", line 1598, in _try_wait
(pid, sts) = os.waitpid(self.pid, wait_flags)
KeyboardInterrupt
并且终端未正确恢复。
如果我以与运行test_read_i.py
文件相同的方式运行,我只会得到:
$ ./test_read_i.py
$ echo hi
hi
没有错误,终端工作。
正如我在对我的问题的评论中所写的那样,当运行read -s
时,bash 会保存当前的 tty 属性,并安装一个add_unwind_protect
处理程序,以便在 read
的堆栈帧退出时恢复以前的 tty 属性。
通常,bash
在启动时安装一个用于SIGINT
的处理程序,该处理程序除其他外,会调用堆栈的完全展开,包括运行所有unwind_protect
处理程序,例如read
添加的处理程序。 但是,通常仅在 bash 以交互模式运行时安装此SIGINT
处理程序。 根据源代码,交互模式仅在以下条件下启用:
/* First, let the outside world know about our interactive status.
A shell is interactive if the `-i' flag was given, or if all of
the following conditions are met:
no -c command
no arguments remaining or the -s flag given
standard input is a terminal
standard error is a terminal
Refer to Posix.2, the description of the `sh' utility. */
我认为这也应该解释为什么我不能简单地通过从 bash 中运行 bash 来重现问题。 但是当我在strace
中运行它时,或者从Python启动的子进程时,我要么使用-c
,要么程序的stderr
不是终端,等等。
正如@Baikuriu他们的回答中发现的那样,就像我在写这篇文章的过程中一样发布,-i
将迫使bash
使用"交互模式",并且它会在自己之后正确清理。
就我而言,我认为这是一个错误。 手册页中记录了stdin
如果不是 TTY,则忽略read
的-s
选项。 但是在我的示例中,stdin
仍然是一个TTY,但是bash在技术上并不处于交互模式,尽管仍然调用交互行为。 在这种情况下,它仍应从SIGINT
正确清理。
对于它的价值,这里有一个特定于 Python 的(但易于推广的)解决方法。 首先,我确保将SIGINT
(并且SIGTERM
良好的度量)传递给子进程。 然后,我将整个subprocess.Popen
调用包装在终端设置的小上下文管理器中:
import contextlib
import os
import signal
import subprocess as sp
import sys
import termios
@contextlib.contextmanager
def restore_tty(fd=sys.stdin.fileno()):
if os.isatty(fd):
save_tty_attr = termios.tcgetattr(fd)
yield
termios.tcsetattr(fd, termios.TCSAFLUSH, save_tty_attr)
else:
yield
@contextlib.contextmanager
def send_signals(proc, *sigs):
def handle_signal(signum, frame):
try:
proc.send_signal(signum)
except OSError:
# process has already exited, most likely
pass
prev_handlers = []
for sig in sigs:
prev_handlers.append(signal.signal(sig, handle_signal))
yield
for sig, handler in zip(sigs, prev_handlers):
signal.signal(sig, handler)
with restore_tty():
p = sp.Popen(['bash', '-c', 'read -s test; echo $test'])
with send_signals(p, signal.SIGINT, signal.SIGTERM):
p.wait()
我仍然对一个解释为什么这是必要的答案感兴趣——为什么 bash 不能更好地清理自己?