如何在 Bash 脚本中清除悬念 (ctrl-z)?

如何在 Bash 脚本中清除悬念 (ctrl-z)?

我有以下脚本:

suspense_cleanup () {
  echo "Suspense clean up..."
}

int_cleanup () {
  echo "Int clean up..."
  exit 0
}

trap 'suspense_cleanup' SIGTSTP 
trap 'int_cleanup' SIGINT

sleep 600

如果我运行它并按Ctrl-C, Int clean up...show 并退出。

但是如果我按Ctrl-Z^Z字符会显示在屏幕上,然后挂起。

我怎么能够:

  • 在 上运行一些清理代码Ctrl-Z,甚至可能回显某些内容,并且
  • 之后继续暂停吗?

随机阅读 glibc 文档,我发现了这一点:

禁用 SUSP 字符正常解释的应用程序应该为用户提供一些其他机制来停止作业。当用户调用此机制时,程序应该向该进程的进程组发送SIGTSTP信号,而不仅仅是向进程本身发送。

但我不确定这是否适用于此,而且无论如何它似乎不起作用。

语境:我正在尝试制作一个交互式 shell 脚本,它支持 Vim/Neovim 支持的所有与悬念相关的功能。即:

  1. 能够以编程方式挂起(使用:suspend,而不只是让用户按 Ctrl-z)
  2. 能够在挂起之前执行操作(Vim 中自动保存)
  3. 能够在恢复之前执行操作(NeoVim 中的 VimResume)

编辑:更改sleep 600for x in {1..100}; do sleep 6; done也不起作用。

编辑2:sleep 600当替换为 时它起作用sleep 600 & wait。我完全不确定它为什么或如何工作,或者这样的东西有什么局限性。

答案1

Linux 和其他类 UNIX 系统上的信号处理是一个非常复杂的主题,涉及许多参与者:内核终端驱动程序、父进程->子进程关系、进程组、控制终端、启用/禁用作业控制的 Shell 信号处理、各个进程中的信号处理程序等等。

首先,Control- C, Control-Z键绑定不是由 shell 处理的,而是由内核处理的。您可以使用以下命令查看默认定义stty -a

$ stty -a
speed 38400 baud; rows 64; columns 212; line = 0;
intr = ^C; quit = ^\; erase = ^H; kill = ^U; eof = ^D; eol = <undef>; eol2 = <undef>; swtch = <undef>; start = ^Q; stop = ^S; susp = ^Z; rprnt = ^R; werase = ^W; lnext = ^V; discard = ^O; min = 1; time = 0;
-parenb -parodd -cmspar cs8 -hupcl -cstopb cread -clocal -crtscts
-ignbrk -brkint -ignpar -parmrk -inpck -istrip -inlcr -igncr icrnl ixon -ixoff -iuclc -ixany -imaxbel iutf8
opost -olcuc -ocrnl onlcr -onocr -onlret -ofill -ofdel nl0 cr0 tab0 bs0 vt0 ff0
isig icanon iexten echo echoe echok -echonl -noflsh -xcase -tostop -echoprt echoctl echoke -flusho -extproc

在这里我们看到intr = ^Csusp = ^Z。 stty 反过来使用 TCGETS ioctl 从内核获取此信息 系统调用:

$ strace stty -a |& grep TCGETS
ioctl(0, TCGETS, {B38400 opost isig icanon echo ...}) = 0

默认的键绑定在 Linux 内核代码中定义

#define INIT_C_CC {         \
        [VINTR] = 'C'-0x40, \
        [VQUIT] = '\\'-0x40,        \
        [VERASE] = '\177',  \
        [VKILL] = 'U'-0x40, \
        [VEOF] = 'D'-0x40,  \
        [VSTART] = 'Q'-0x40,        \
        [VSTOP] = 'S'-0x40, \
        [VSUSP] = 'Z'-0x40, \
        [VREPRINT] = 'R'-0x40,      \
        [VDISCARD] = 'O'-0x40,      \
        [VWERASE] = 'W'-0x40,       \
        [VLNEXT] = 'V'-0x40,        \
        INIT_C_CC_VDSUSP_EXTRA      \
        [VMIN] = 1 }

还定义了默认操作:

static void n_tty_receive_char_special(struct tty_struct *tty, unsigned char c,
                                       bool lookahead_done)
{
        struct n_tty_data *ldata = tty->disc_data;

        if (I_IXON(tty) && n_tty_receive_char_flow_ctrl(tty, c, lookahead_done))
                return;

        if (L_ISIG(tty)) {
                if (c == INTR_CHAR(tty)) {
                        n_tty_receive_signal_char(tty, SIGINT, c);
                        return;
                } else if (c == QUIT_CHAR(tty)) {
                        n_tty_receive_signal_char(tty, SIGQUIT, c);
                        return;
                } else if (c == SUSP_CHAR(tty)) {
                        n_tty_receive_signal_char(tty, SIGTSTP, c);
                        return;
                }

信号最终到达 __kill_pgrp_info() ,其中显示:

/*
 * __kill_pgrp_info() sends a signal to a process group: this is what the tty
 * control characters do (^C, ^Z etc)
 * - the caller must hold at least a readlock on tasklist_lock
 */

Control这对我们的故事很重要 - 使用-CControl-生成的信号 Z被发送到前台进程组由父交互式 shell 创建,其领导者是新运行的脚本。脚本及其孩子们属于一个群体。

因此,正如用户评论中正确指出的那样卡米尔·马乔罗夫斯基,当您发送Control-Z启动脚本后 SIGTSTP 信号均被脚本接收 sleep 因为当一个信号发送到一个组时,该组中的所有进程都会收到该信号。很容易看出您是否从代码中删除了陷阱,使其看起来像这样(顺便说一句,总是添加一个 https://en.wikipedia.org/wiki/shebang_(unix),没有定义如果没有 shebang 会发生什么))

#!/usr/bin/env bash

# suspense_cleanup () {
#   echo "Suspense clean up..."
# }

# int_cleanup () {
#   echo "Int clean up..."
#   exit 0
# }

# trap 'suspense_cleanup' SIGTSTP
# trap 'int_cleanup' SIGINT

sleep 600

运行它(我将其命名为 sigtstp.sh)并停止它:

$ ./sigtstp.sh
^Z
[1]+  Stopped                 ./sigtstp.sh
$ ps aux | grep -e '[s]leep 600' -e '[s]igtstp.sh'
ja       27062  0.0  0.0   6908  3144 pts/25   T    23:50   0:00 sh ./sigstop.sh
ja       27063  0.0  0.0   2960  1664 pts/25   T    23:50   0:00 sleep 600

ja是我的用户名,你的用户名会不同,PID 也会不同,但重要的是两个进程都处于停止状态,如字母“T”所示。从man ps

PROCESS STATE CODES
(...)
T    stopped by job control signal

这意味着两个进程都收到了 SIGTSTP 信号。现在,如果两个进程(包括 sigstop.sh)都收到信号,为什么 suspense_cleanup()信号处理程序不运行? Bash 直到终止才执行它sleep 600。它的要求是由 POSIX:

当 shell 等待实用程序执行前台命令完成时接收到已设置陷阱的信号时,与该信号关联的陷阱只有在前台命令完成后才会执行。

(请注意,尽管在开源世界中,IT 一般标准只是提示的集合,并且没有法律要求强迫任何人遵循它们)。如果你睡得少一点,比如说 3 秒,那也是没有帮助的,因为sleep无论如何进程都会停止,所以它永远不会完成。为了suspense_cleanup()立即被调用,我们必须在后台运行它并wait按照上面的 POSIX 链接中的说明运行:

#!/usr/bin/env bash

suspense_cleanup () {
  echo "Suspense clean up..."
}

int_cleanup () {
  echo "Int clean up..."
  exit 0
}

trap 'suspense_cleanup' SIGTSTP
trap 'int_cleanup' SIGINT

sleep 600 &
wait

运行它并停止它:

$ ./sigstop.sh
^ZSuspense clean up...

请注意,sleep 600sigtstp.sh现在都消失了:

$ ps aux | grep -e '[s]leep 600' -e '[s]igtstp.sh'
$

很明显为什么 sigtstp.sh 消失了 -wait被信号中断,它是脚本中的最后一行,因此它退出。更令人惊讶的是,当您意识到如果您发送 SIGINT,即使在 sigtstp.sh 死亡后睡眠仍然会运行:

$ ./sigtstp.sh
^CInt clean up...
$ ps aux | grep -e '[s]leep 600' -e '[s]igtstp.sh'
ja       32354  0.0  0.0   2960  1632 pts/25   S    00:12   0:00 sleep 600

但是,由于其父级死亡,它将被 init 采用:

$ grep PPid /proc/32354/status
PPid:   1

原因是当 shell 在后台运行子进程时,它会禁用默认的 SIGINT 处理程序,该处理程序将终止其中的进程 (signal(7))](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html):

如果在 shell 执行异步列表时禁用作业控制(请参阅 set -m 的说明),则列表中的命令将从 shell 继承 SIGINT 和 SIGQUIT 信号的忽略 (SIG_IGN) 信号操作。在所有其他情况下,shell 执行的命令应继承与 shell 从其父级继承的信号操作相同的信号操作,除非信号操作被 trap 特殊内置函数修改(请参阅 trap)

一些SO参考: https://stackoverflow.com/questions/46061694/bash-why-cant-i-set-a-trap-for-sigint-in-a-background-shell/46061734#46061734, https://stackoverflow.com/questions/45106725/why-do-shells-ignore-sigint-and-sigquit-in-backgrounded-processes。如果您想在收到 SIGINT 后杀死所有子进程,则必须在陷阱处理程序中手动执行此操作。但是请注意,SIGINT 仍然会传递给所有子级,但只是被忽略 - 如果您没有使用 sleep,而是安装了自己的 SIGINT 处理程序的命令(例如,尝试 tcpdump)!glibc手册 说:

请注意,如果先前将给定信号设置为忽略,则此代码会避免更改该设置。这是因为非作业控制 shell 在启动子进程时通常会忽略某些信号,因此子进程尊重这一点非常重要。

但是,如果我们自己不杀死它,那么为什么在向它发送 SIGTSTP 后 sleep 就死掉了,而 SIGTSTP 应该只阻止它,而不是杀死它呢?属于孤立进程组的所有已停止进程从内核获取 SIGHUP:

如果进程的退出导致进程组成为孤立进程组,并且新孤立进程组的任何成员停止,则应向新孤立进程组中的每个进程发送一个 SIGHUP 信号,后跟一个 SIGCONT 信号。

如果没有安装任何自定义处理程序,SIGHUP 将终止该进程(signal(7)):

Signal      Standard   Action   Comment
SIGHUP       P1990      Term    Hangup detected on controlling terminal
                                or death of controlling process

(请注意,如果您在 strace 下运行 sleep ,事情会变得更加复杂......)。

好的,那么回到你原来的问题怎么样:

我怎么能够:

按 Ctrl-Z 运行一些清理代码,甚至可能回显某些内容,然后继续暂停?

我会这样做的方式是:

#!/usr/bin/env bash

suspense_cleanup () {
    echo "Suspense clean up..."
    trap - SIGTSTP
    kill -TSTP $$
    trap 'suspense_cleanup' SIGTSTP
}

int_cleanup () {
    echo "Int clean up..."
    exit 0
}

trap 'suspense_cleanup' SIGTSTP
trap 'int_cleanup' SIGINT
sleep 600 &
while true
do
    if wait
    then
        echo child died, exiting
        exit 0
    fi
done

Nowsuspense_cleanup()将在停止进程之前调用:

$ ./sigtstp.sh
^ZSuspense clean up...

[1]+  Stopped                 ./sigtstp.sh
$ ps aux | grep -e '[s]leep 600' -e '[s]igtstp.sh'
ja        4129  0.0  0.0   6920  3196 pts/25   T    00:29   0:00 bash ./sigtstp.sh
ja        4130  0.0  0.0   2960  1660 pts/25   T    00:29   0:00 sleep 600
$ fg
./sigtstp.sh
^ZSuspense clean up...

[1]+  Stopped                 ./sigtstp.sh
$  fg
./sigtstp.sh
^CInt clean up...
$ ps aux | grep -e '[s]leep 600' -e '[s]igtstp.sh'
ja        4130  0.0  0.0   2960  1660 pts/25   S    00:29   0:00 sleep 600
$ grep PPid /proc/4130/status
PPid:   1

你可以少睡一点,比如说 10 秒,然后看看如果睡眠完成,脚本就会退出:

#!/usr/bin/env bash

suspense_cleanup () {
    echo "Suspense clean up..."
    trap - SIGTSTP
    kill -TSTP $$
    trap 'suspense_cleanup' SIGTSTP
}

int_cleanup () {
    echo "Int clean up..."
    exit 0
}

trap 'suspense_cleanup' SIGTSTP
trap 'int_cleanup' SIGINT
# sleep 600 &
sleep 10 &
while true
do
    if wait
    then
        echo child died, exiting
        exit 0
    fi
done

运行:

$ time ./sigtstp.sh
child died, exiting

real    0m10.007s
user    0m0.003s
sys     0m0.004s

答案2

看来 Bash 作业控制会干扰正常的 tty 特殊字符。 SIGTSTP 可能会发送到 Bash,但不会发送到正在运行的进程。

https://www.gnu.org/software/bash/manual/bash.html#Job-Control-Basics:

如果运行 Bash 的操作系统支持作业控制,则 Bash 包含使用它的工具。在进程运行时键入挂起字符(通常为“^Z”、Control-Z)会导致该进程停止并将控制权返回给 Bash。

继续(Ctrl-Q,start在 中调用stty)在 Bash 作业控制下不起作用:您需要fgbg进程来取消停止它。

答案3

在阅读man 7 signal, 或 时 https://www.man7.org/linux/man-pages/man7/signal.7.html,人们会看到:

信号 SIGKILL 和 SIGSTOP 无法被捕获、阻止或忽略

所以,你不能。

相关内容