In the code "{ exec >/dev/null; } >/dev/null" what is happening under the hood?

Let's follow

{ exec >/dev/null; } >/dev/null; echo "Hi"

step by step.

  1. There are two commands:

    a. { exec >/dev/null; } >/dev/null, followed by

    b. echo "Hi"

    The shell executes first the command (a) and then the command (b).

  2. The execution of { exec >/dev/null; } >/dev/null proceeds as follows:

    a. First, the shell perform the redirection >/dev/null and remembers to undo it when the command ends.

    b. Then, the shell executes { exec >/dev/null; }.

    c. Finally, the shell switches standard output back to where is was. (This is the same mechanism as in ls -lR /usr/share/fonts >~/FontList.txt -- redirections are made only for the duration of the command to which they belong.)

  3. Once the first command is done the shell executes echo "Hi". Standard output is wherever it was before the first command.


In order to not use a sub-shell or sub-process, when the output of a compound list {} is piped >, the shell saves the STDOUT descriptor before running the compound list and restores it after. Thus the exec > in the compound list does not carry its effect past the point where the old descriptor is reinstated as STDOUT.

Let's take a look at the relevant part of strace bash -c '{ exec >/dev/null; } >/dev/null; echo hi' 2>&1 | cat -n:

   132  open("/dev/null", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 3
   133  fcntl(1, F_GETFD)                       = 0
   134  fcntl(1, F_DUPFD, 10)                   = 10
   135  fcntl(1, F_GETFD)                       = 0
   136  fcntl(10, F_SETFD, FD_CLOEXEC)          = 0
   137  dup2(3, 1)                              = 1
   138  close(3)                                = 0
   139  open("/dev/null", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 3
   140  fcntl(1, F_GETFD)                       = 0
   141  fcntl(1, F_DUPFD, 10)                   = 11
   142  fcntl(1, F_GETFD)                       = 0
   143  fcntl(11, F_SETFD, FD_CLOEXEC)          = 0
   144  dup2(3, 1)                              = 1
   145  close(3)                                = 0
   146  close(11)                               = 0
   147  dup2(10, 1)                             = 1
   148  fcntl(10, F_GETFD)                      = 0x1 (flags FD_CLOEXEC)
   149  close(10)                               = 0

You can see how, on line 134, descriptor 1 (STDOUT) is copied onto another descriptor with index at least 10 (that's what F_DUPFD does; it returns the lowest available descriptor starting at the given number after duplicating onto that descriptor). Also see how, on line 137, the result of open("/dev/null") (descriptor 3) is copied onto descriptor 1 (STDOUT). Finally, on line 147, the old STDOUT saved on descriptor 10 is copied back onto descriptor 1 (STDOUT). The net effect is to insulate the change to STDOUT on line 144 (which corresponds to the inner exec >/dev/null).


The difference between { exec >/dev/null; } >/dev/null; echo "Hi" and { exec >/dev/null; }; echo "Hi" is that the double redirection does dup2(10, 1); before closing fd 10 which is the copy of the original stdout, before running the next command (echo).

It happens that way because the outer redirect is actually overlaying the inner redirect. That's why it copies back the original stdout fd once it completes.