💾 Archived View for thrig.me › blog › 2024 › 11 › 30 › yet-more-sigint-foo.gmi captured on 2024-12-17 at 10:41:00. Gemini links have been rewritten to link to archived content

View Raw

More Information

-=-=-=-=-=-=-

Yet More SIGINT Foo

There are various misunderstandings of what happens when control+c is mashed in a unix terminal, this time when a shell runs a pipeline. The claim is that the SIGINT goes to the last process in a pipeline, and the other processes get a SIGPIPE.

INTR Special character on input and is recognized if the ISIG flag
(see the Local Modes section) is enabled. Generates a SIGINT
signal which is sent to all processes in the foreground process
group for which the terminal is the controlling terminal. If
ISIG is set, the INTR character is discarded when processed.
— termios(4) on OpenBSD

As a simple test we can wire up something like the shell pipeline "yes | cat | cat >/dev/null" only with better logging. One could also use ktrace or similar and study the relevant signal calls involved, though that can be noisy as shell job control may involve much fiddling around with signal masks, and who knows what overhead higher level programming environments have added by default. How exactly the shell implements job control and therefore what exactly is in the foreground process group may also be a concern. The following involves ksh(1) on OpenBSD.

    // pa - yes(1) but with logging of signals
    #include <signal.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <unistd.h>
    void ouch(int signo) {
        fprintf(stderr, "pa (%d) pid=%d pgrp=%d\n", signo, getpid(),
                tcgetpgrp(STDERR_FILENO));
        exit(1);
    }
    int main(void) {
        signal(SIGINT, ouch);
        signal(SIGPIPE, ouch);
        while (1) puts("y");
    }

pa.c

    // re - cat(1) but with logging of signals
    #include <signal.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <unistd.h>
    void ouch(int signo) {
        fprintf(stderr, "pa (%d) pid=%d pgrp=%d\n", signo, getpid(),
                tcgetpgrp(STDERR_FILENO));
        exit(1);
    }
    int main(int argc, char *argv[]) {
        signal(SIGINT, ouch);
        signal(SIGPIPE, ouch);
        char *line      = NULL;
        size_t linesize = 0;
        ssize_t linelen;
        while ((linelen = getline(&line, &linesize, stdin)) != -1)
            fwrite(line, linelen, 1, stdout);
    }

re.c

    $ make pa re                   
    cc -O2 -pipe    -o pa pa.c 
    cc -O2 -pipe    -o re re.c 
    $ ./pa | ./re | ./re >/dev/null
    ^Cre (2) pid=31755 pgrp=97631
    pa (2) pid=97631 pgrp=97631
    re (2) pid=76442 pgrp=97631
    $ kill -l | grep INT
     2    INT Interrupt                     18   TSTP Suspended                

This shows SIGINT (2) handled by all three or more processes involved in process group 97631; the "or more" is because the shell the pipeline is running in may have a process in the foreground process group as well, one that is set to mask off or otherwise handle various signals. However that is not a concern here, as a SIGINT goes to each process in the pipeline, not only to the last process. (Some have the notion that a single process receives the signal, and then somehow rebroadcasts signals elsewhere, unlike what the termios(4) documentation says. Extraordinary claims need extraordinary evidence.)

Where then did the notion that a SIGPIPE is received by the other processes come from? One could start with an incorrect assumption and reason from there, or maybe they did observe other processes getting a SIGPIPE, possibly because the signal handling in the software involved was more complicated?

What happens if we gather more samples?

    $ ./pa | ./re | ./re >/dev/null
    ^Cpa (2) pid=94176 pgrp=94176
    re (2) pid=33501 pgrp=94176
    re (2) pid=70826 pgrp=94176
    $ ./pa | ./re | ./re >/dev/null
    ^Cre (2) pid=16270 pgrp=45890
    pa (2) pid=45890 pgrp=45890
    re (2) pid=38669 pgrp=45890
    re (13) pid=38669 pgrp=45890
    pa (13) pid=45890 pgrp=45890
    $ ./pa | ./re | ./re >/dev/null
    ^Cre (2) pid=76009 pgrp=58438
    re (2) pid=20319 pgrp=58438
    pa (2) pid=58438 pgrp=58438
    re (13) pid=20319 pgrp=58438
    $ kill -l | grep PIPE
    13   PIPE Broken pipe                   29   INFO Information request      

What this shows is a race condition where the SIGPIPE handler is sometimes able to execute the printf before the SIGINT handler can exit the process. This will be more obvious on a busy system (where the processes involved take longer to see themselves to the door) and more easily missed on a fast and unloaded system. More correctly one might mask off other signals inside the handler, or better yet to only set some flag that is acted on outside of the thus minimal signal handler (and to also mask signals in the relevant portions of the code, which is not done here).

    // ci - cat(1) but with more atomic logging of signals
    #include <signal.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <unistd.h>
    sig_atomic_t Must_Exit;
    void ouch(int signo) { Must_Exit = signo; }
    int main(int argc, char *argv[]) {
        signal(SIGINT, ouch);
        signal(SIGPIPE, ouch);
        char *line      = NULL;
        size_t linesize = 0;
        ssize_t linelen;
        while ((linelen = getline(&line, &linesize, stdin)) != -1) {
            fwrite(line, linelen, 1, stdout);
            if (Must_Exit) {
                fprintf(stderr, "re (%d) pid=%d pgrp=%d\n", Must_Exit,
                        getpid(), tcgetpgrp(STDERR_FILENO));
                exit(1);
            }
        }
    }

ci.c

The above code does not use any masks (see sigprocmask(2) for details) but probably should as a SIGPIPE could change the value in Must_Exit after the SIGINT handler completes but before the fprintf can be executed. So there is less of a race condition than in re.c (two fprintf cannot run) but still a race condition.

If SIGINT is handled or otherwise masked off, then that process in a pipeline will get a SIGPIPE as it tries to do I/O with things that have gone away. A process that masks or handles both SIGINT and SIGPIPE might be problematic in a pipeline, though commonly TERM, HUP, and KILL signals are resorted to, depending on how stuck the process is, and how adventurous (in the case of KILL) the kill(1)-wielder is.

A more likely case is that one has complicated software with different layers of flag-setting signal handling (perhaps so that event loops can complete and threads be yielded, database and network connections closed, etc) that may take time for signals to eventually cause the process to exit; one might imagine that SIGINT is flagged (or blocked, as some LISP folks do not like their REPL going away so easily) but then the SIGPIPE code takes priority and that's what the process exits with. Confirming whether this is the case would require either studying the code involved or more likely running it under ktrace or similar to follow the signal system calls. However, SIGINT is sent to all processes in the foreground process group.

A shell might do funky things with process groups, in particular which one is in the foreground. One may then have a pipeline that a control+c does not reach, as those processes are not in the foreground process group. So debugging what is going on involves learning what all is in the foreground process group, what signal handlers and masks are set in those processes, and whether ISIG is enabled in the terminal that is shared by all the processes.

P.S. for portable code one wants sigaction(2) instead of signal(3).