Search code examples
cbashexecwaitrace-condition

Race condition between piped command execution in a shell implementation


(I'm not sure if I chose the right wording in the heading so correct me if it sounds wrong, same about tags)

I'm writing a shell implementation which must be able to carry out piped commands. Here's a simplified piece of code which executes ls | wc -l (wc can be replaced with sleep or date):

#include <signal.h>
#include <unistd.h>
#include <stdio.h>
#include <sys/wait.h>
#include <stdlib.h>

pid_t   family[2];
int     pipefd[2];
extern char **environ;

void    ft_exec(int i)
{
    char **ls = malloc(sizeof(char *) * 2);
    ls[0] = "/bin/ls";
    ls[1] = NULL;

    char **wc = malloc(sizeof(char *) * 3);
    wc[0] = "/usr/bin/wc";
    wc[1] = "-l";
    wc[2] = NULL;

    printf("ENTERED BY %d\n", getpid());
    if (i == 0)
    {
        dup2(pipefd[1], 1);
        close(pipefd[0]);
        close(pipefd[1]);
        execve(ls[0], ls, environ);
    }
    if (i == 1)
    {
        dup2(pipefd[0], 0);
        close(pipefd[0]);
        close(pipefd[1]);
        execve(wc[0], wc, environ);
    }
}

void    ft_block_main_process(void)
{
    int     i;
    pid_t   terminated;
    int     status;

    i = 0;
    while (i < 2)
    {
        terminated = waitpid(-1, &status, 0);
        printf("%d TERMINATED; WEXITSTATUS:%d, WTERMSIG:%d, STATUS:%d\n", terminated, WEXITSTATUS(status), WTERMSIG(status), status);
        if (terminated == family[0])
            close(pipefd[1]);
        else if (terminated == family[1])
            close(pipefd[0]);
        i++;
    }
}

void    ft_interpret(void)
{
    int i;

    i = 0;
    while (i < 2)
    {
        family[i] = fork();
        if (family[i] == 0)
            ft_exec(i);
        i++;
    }
    ft_block_main_process();
}

int main(int argc, char **argv)
{
    (void)argc;
    (void)argv;

    pipe(pipefd);
    ft_interpret();
}

The output is as follows:

ENTERED BY 1511
ENTERED BY 1512
1511 TERMINATED; WEXITSTATUS:0, WTERMSIG:0, STATUS:0
       3
1512 TERMINATED; WEXITSTATUS:0, WTERMSIG:0, STATUS:0

which is correct.

However, if I try changing the second command to, say, printenv, it blows up:

#include <signal.h>
#include <unistd.h>
#include <stdio.h>
#include <sys/wait.h>
#include <stdlib.h>

pid_t   family[2];
int     pipefd[2];
extern char **environ;

void    ft_exec(int i)
{
    char **ls = malloc(sizeof(char *) * 2);
    ls[0] = "/bin/ls";
    ls[1] = NULL;

    char **printenv = malloc(sizeof(char *) * 2);
    printenv[0] = "/usr/bin/printenv";
    printenv[1] = NULL;

    printf("ENTERED BY %d\n", getpid());
    if (i == 0)
    {
        dup2(pipefd[1], 1);
        close(pipefd[0]);
        close(pipefd[1]);
        execve(ls[0], ls, environ);
    }
    if (i == 1)
    {
        dup2(pipefd[0], 0);
        close(pipefd[0]);
        close(pipefd[1]);
        execve(printenv[0], printenv, environ);
    }
}

void    ft_block_main_process(void)
{
    int     i;
    pid_t   terminated;
    int     status;

    i = 0;
    while (i < 2)
    {
        terminated = waitpid(-1, &status, 0);
        printf("%d TERMINATED; WEXITSTATUS:%d, WTERMSIG:%d, STATUS:%d\n", terminated, WEXITSTATUS(status), WTERMSIG(status), status);
        if (terminated == family[0])
            close(pipefd[1]);
        else if (terminated == family[1])
            close(pipefd[0]);
        i++;
    }
}

void    ft_interpret(void)
{
    int i;

    i = 0;
    while (i < 2)
    {
        family[i] = fork();
        if (family[i] == 0)
            ft_exec(i);
        i++;
    }
    ft_block_main_process();
}

int main(int argc, char **argv)
{
    (void)argc;
    (void)argv;

    pipe(pipefd);
    ft_interpret();
}

The output is:

ENTERED BY 1937
ENTERED BY 1938
TMPDIR=/var/folders/zz/zyxvpxvq6csfxvn_n000ccj800334_/T/
<...> // environ stuff
_=/Users/aisraely/testing/./a.out
1938 TERMINATED; WEXITSTATUS:0, WTERMSIG:0, STATUS:0
1937 TERMINATED; WEXITSTATUS:0, WTERMSIG:13, STATUS:13

Now if the same is run in bash, it exits with 0:

bash-3.2$ ls | printenv
TERM_PROGRAM=iTerm.app
<...> // environ stuff
_=/usr/bin/printenv
bash-3.2$ echo $?
0

I do realize that, according to the manpage of pipe(), writing on a widowed pipe forces the writing process to terminate by receiving a SIGPIPE (whose value is properly extracted by the WTERMSIG() macro), and that printenv exits earlier, ls tries to write to it but sees no reader and catches SIGPIPE. But why in some cases this is not true? Why my executing program doesn't exit with a 0 when it should? Is it because of ls?


Solution

  • I think this is normal behavior. The exit status of the shell pipeline is usually the exit status of the rightmost command on the pipeline.

    The GNU Bash shell has a pipefail option that can be enabled. When enabled, the exit status of the pipeline is the exit status of the rightmost command that exited with a non-zero exit status.

    For example:

    bash$ set -o pipefail  # turn pipefail option on
    bash$ seq 0 1000000 | date
    Thu Aug 26 14:49:51 UTC 2021
    bash$ echo $?
    141
    bash$ set +o pipefail  # turn pipefail option off
    bash$ seq 0 1000000 | date
    Thu Aug 26 14:50:10 UTC 2021
    bash$ echo $?
    0
    bash$ 
    

    The exit status 141 is due to the seq command being terminated by a SIGPIPE signal.