尝试让我自己的 shell 正确处理 ctrl+c

尝试让我自己的 shell 正确处理 ctrl+c

我试图了解 shell 是如何设置的,以便当您按 ctrl C 时,它们正在运行的程序会收到 SIGINT,但 shell 不会,因为当您运行 bash 时,并在内部运行另一个程序,然后按 CTRL +C,您正在运行的程序停止,但 bash 没有。

当您从另一个 shell 中启动 bash 时,这甚至适用。

我尝试制作自己的特殊 shell 程序,当您按任意键时,它只运行一个子程序。

#! /usr/bin/env bash

set -e

while true
do
    echo press any key to run $@
    read -n 1 -r -s key
    code=$(echo $key | cat -v)
    if [[ "$code" == "^D" ]]
    then
        echo bye
        exit 0
    fi
    echo running $@
    $@ || echo oops
done

这就是它的作用:

$ ./shell.sh sleep 1
press any key to run sleep 1
running sleep 1
press any key to run sleep 1
running sleep 1
press any key to run sleep 1
bye

按任意键,它会运行您作为参数传入的程序,然后在提示符下按 ctrl+d 退出

但是,如果我尝试使用 ctrl+c 停止睡眠程序:

$ ./shell.sh sleep 10
press any key to run sleep 10
running sleep 10
^C
$

它也会停止 shell 程序——这是预期的。

然而,当你bash单独运行时,它的行为就不是这样了。

我希望我的工作像交互式 bash - 允许“睡眠”命令退出,然后继续执行提示符。

我也尝试过运行它-m,以防万一这就是窍门:

$ bash -m shell.sh sleep 10
press any key to run sleep 10
running sleep 10
^C$

但它也会退出。

所以我有两个问题:(可能有相同的答案)

  • 我怎样才能告诉 bash 表现得像交互运行一样,并且当我按 ctrl+c 时不退出,只允许子程序终止并继续到提示符处。

  • bash 内部在做什么?例如,当我想在 python 中制作相同的 shell 程序时(我也这样做了,但它没有包含在这里)我必须做什么,因为它没有可能回答上述问题的 bash 选项。我知道我可以通过传递信号来使其工作,但我想知道我的做法与真正的 shell 的做法相同。

我想我可以通过处理我的“shell”中的信号来使其工作,但有些东西告诉我这不是真正的shell的工作方式 - 而且我不断听到有关前景和组的内容 - 所以我希望可以将其插入到我的示例中 -或者至少有一个关于为什么它不能插入到我的示例中的解释。

答案1

初步说明

您的“特殊 shell 程序”存在一些与主要问题无关的问题,但如果您想继续使用该程序,请考虑修复它们:

  1. 正确引用

  2. 将扩展$@(甚至"$@",见上文)作为命令传递允许用户运行 shell 内置命令,包括更改解释 shell 本身状态的内置命令: try./shell.sh set -x./shell.sh exit。至少"$@"在子 shell 中运行(这将接受 shell 内置命令),但最好exec -- "$@"在子 shell 中运行(这不会)。

  3. 避免echo,除非您要回显固定字符串。使用printf

  4. 你不需要cat -vBash 可以生成 EOT 字符 ( ^D)并将你的$key与它进行比较。

你也不需要[[这里,[就可以了(它们不等价尽管)。字符串比较的基本运算符[=,Bash 可以理解or==后面的内容,其他 shell 可能也可能不理解。在足够的地方使用和不使用是我个人的偏好。这样我就不会习惯不可移植的代码,并且我知道我可以在不像 Bash 那样丰富的 shell 中做什么。需要明确的是:这是[[[=[[[我个人的喜好[[在你的代码中不是问题。

这是改进的脚本(但它仍然像原始脚本一样(错误)处理Ctrl+ c,这将在稍后解决):

#! /usr/bin/env bash
set -e
while true
do
    printf 'press any key to run %s\n' "${*@Q}"
    read -n 1 -r -s key
    if [ "$key" = $'\cD' ]
    then
        echo bye
        exit 0
    fi
    printf 'running %s\n' "${*@Q}"
    (exec -- "$@") || echo oops
done

该代码还使用$*在适当情况下随着@Q修饰语明确地打印命令。尝试./shell.sh echo "a b"与原始脚本的行为进行比较。

现在进入正题。


说到重点

Ctrl+c终端的线路规则(通常)将 SIGINT 发送到前台进程组中的进程。shell 可能在组中,也可能不在组中,因此它可能会也可能不会收到信号。它不中继信号。

前台进程组是当前在控制终端中“处于前台”的进程组。通常它是一个交互式 shell,不断通知控制终端哪个组在前台。

一般来说,即将运行子进程的未来父进程(如您的“shell”)应决定新进程是否位于同一进程组(即旧进程、父进程)或新进程组中。看这个答案:有没有办法更改正在运行的进程的进程组?。如果新的进程组应该是前台的进程组,应通知终端;之后,当子进程退出时,前台进程组应设置回父进程组。

因此,根据您的“shell”是否以及如何处理进程组,当Ctrl+c导致 SIGINT 被发送到前台进程组时,信号将被传递:

  • 如果子进程的(新)组是前台进程组并且 shell 位于不同的(通常是旧的)进程组中,则发送到子进程,但不发送到“shell”;请注意,子进程组可能包含(部分或全部)其后代,并且它们也会收到信号;
  • 到“shell”和子进程(+后代),如果它们位于前台进程组中;实现这一点的最简单方法是不更改子进程组(即将所有内容保留在 shell 的永远前台进程组内,就好像进程组的概念不存在一样);
  • 到“shell”,但不到子进程(+后代),如果它们位于不同的进程组中并且前台进程组恰好是“shell”之一。

这意味着防止Ctrl+c中断“shell”的一种方法是确保“shell”不在前台进程组中。

另一种方法是允许“外壳”位于前台进程组中并处理信号。这些是您的选择:

  • 您的“shell”可能不会执行任何特殊操作,SIGINT 将终止它。这是你想要避免的。严格来说,如果从其父级继承的忽略掩码这么说,那么“不执行任何操作”的“shell”将忽略 SIGINT,但这是一种边缘情况。
  • 您的“shell”可能会明确选择忽略 SIGINT。如果您的脚本由 解释bash,您需要trap '' INT;如果您不希望子进程忽略 SIGINT(因为继承),那么您应该重置为子 shell 中的原始配置exec(trap - INT; exec -- "$@");但请注意,“原始配置”意味着“它在进入时具有的值” shell”,所以在边缘情况下你可能无法让孩子不忽略 SIGINT)。
  • 你的“外壳”可能会捕获 SIGINT 并执行某些操作。如果您的脚本由 解释bash,则您需要trap 'shell code' INTwhereshell code甚至可以是无操作 ( :) 或空格,但不能是空字符串。孩子会表现得好像陷阱不存在一样。注意 bash 无法捕获或重置进入 shell 时忽略的信号。

这是我们的带有陷阱的脚本:

#! /usr/bin/env bash
set -e
trap 'echo "the shell got SIGINT"' INT
while true
do
    printf 'press any key to run %s\n' "${*@Q}"
    read -n 1 -r -s key
    if [ "$key" = $'\cD' ]
    then
        echo bye
        exit 0
    fi
    printf 'running %s\n' "${*@Q}"
    (exec -- "$@") || echo oops
done

在不同阶段./shell.sh sleep 10Ctrl+来运行并进行实验。c您应该发现,当sleep运行时,来自Ctrl+的 SIGINTc到达它并中断它;信号进入解释 shell(您会看到陷阱正在运行)。这是因为在同一进程组中bash运行并且两个进程都会收到 SIGINT。sleep

如果您-m通过运行强制(作业控制) ,则来自+bash -m shell.sh sleep 10的 SIGINT在发送过程中将到达解释 shell ,但在发送过程中不会到达解释 shell 。因为并运行在不同的进程组中(可以确认)。是 Bash 中的内置函数;当它在前景中时,就在前景中。当位于前台时,则不是。到达 的 SIGINT不会传递到。当 SIGINT 到达时,陷阱是无用的,但当 SIGINT 到达期间时,陷阱仍然有用。如果不是陷阱,+期间会打断整个。Ctrlcreadsleep-m bashsleepps -ao pid,pgrp,cmdreadbashsleepbashsleepbashsleepbashreadCtrlcreadbash

有点令人惊讶的是我们的bash -m(特别是我测试过的 Bash 5.2.15)sleep当被 SIGINT 杀死时退出。它没有执行就退出echo oops,退出状态为130,就好像shell本身被SIGINT杀死一样;或者就好像它中继了最后执行的命令的退出状态(这是 shell 通常所做的),在这种情况下,sleep肯定会被 SIGINT 杀死。

这种行为实际上是有道理的。这篇文章解释了这一点:正确处理 SIGINT/SIGQUIT。你不需要现在就读它;如果你想写一个“shell”,那么我认为你应该在某个时候阅读它。对于我们来说,以下 Bash 手册的摘录是相关的:

如果命令由于 SIGINT 而终止,Bash 会断定用户打算结束整个脚本,并对 SIGINT 采取行动(例如,通过运行 SIGINT 陷阱或退出自身);

(来源

这是在“当作业控制未启用时”(更广泛的片段可能会让您感兴趣)。我还没有找到任何关于启用作业控制时会发生什么的描述,但从我们观察到的行为bash -m我推断“Bash 得出结论,用户打算结束整个脚本”仍然适用。我的解释是:尽管事实上我们bash没有收到SIGINT(因为它目前不在前台进程组中),但它仍然看到sleep由于SIGINT而终止,因此得出结论用户打算结束整个脚本;但它不会运行陷阱,因为它实际上没有收到信号;它不会自杀(我用 确认了这一点strace),它只是中继 的退出状态sleep

编写“shell”时,请决定是否要在 shell 的进程组中运行命令。处理 SIGINT。然后,您可能(或可能不)希望您的“shell”检测最后一个命令是否被 SIGINT 终止或刚刚退出。一般来说,实际处理(如捕获,而不是忽略)SIGINT 并注意到它在命令执行期间发生可能有助于决定命令退出后要做什么。

trap是的,即使用户没有明确定义 s,真正的 shell 也会捕获信号。例如,在 Linux 中,您可以grep ^Sig /proc/"$$"/status根据以下答案运行和解释 SigCgt:如何检查进程正在监听哪些信号?

现在是时候阅读已经链接的文章看看“真正的贝壳”处理什么。


其他注意事项

  • 我写了“在Ctrl+c您的终端的线路规则(通常)发送SIGINT”。它是“通常”,因为程序(包括从“shell”运行的程序)可以配置它(但是并非所有程序都通用)。例如,如果至少./shell.sh stty intr ^D执行stty intr ^D一次,那么您将无法轻松退出循环,因为Ctrl+d将生成 SIGINT 并且read永远不会读取^D

  • 在 shell 中,类似bash“在后台运行”与&命令终止符相关联,但&并不意味着进程不在前台进程组中启动。前台进程组、SIGTTIN、SIGTTOU 和整个作业控制的概念是一种拒绝某些进程访问控制终端的方法,同时能够按需允许它们(即将它们移动到前台)fg。如果没有作业控制,则启动的进程&是异步的(shell 不会等待它完成),并且其标准输入被重定向到/dev/null或某个等效文件,因此它无法从终端“窃取”,从这个意义上说,它是“在后台” ;但它可以在前台进程组中。例如,将在单个进程组(是 的父进程组,或新进程组,取决于父进程如何设置)中bash -c 'sleep 500 & sleep 600'运行新进程bash和两个进程,该进程组将成为前台进程组。sleepbash

  • 如果您想继续使用 shell 脚本测试这些内容,请小心使用set -e.如果脚本因为 退出set -e,有时可能很容易认为它是因为 SIGINT 退出。对于我们的脚本,如果您删除|| echo oops.

相关内容