Bash 'swallowing' sub-shell children process when executing a single command
Asked Answered
C

1

11

Bumped into an unexpected bash/sh behavior and I wonder someone can explain the rationale behind it, and provide a solution to the question below.

In an interactive bash shell session, I execute:

$ bash -c 'sleep 10 && echo'

With ps on Linux it looks like this:

\_ -bash \_ bash -c sleep 10 && echo \_ sleep 10

The process tree is what I would expect:

  • My interactive bash shell process ($)
  • A children shell process (bash -c ...)
  • a sleep children process

However, if the command portion of my bash -c is a single command, e.g.:

$ bash -c 'sleep 10'

Then the middle sub-shell is swallowed, and my interactive terminal session executes sleep "directly" as children process. The process tree looks like this:

\_ -bash \_ sleep 10

So from process tree perspective, these two produce the same result:

  • $ bash -c 'sleep 10'
  • $ sleep 10

What is going on here?

Now to my question: is there a way to force the intermediate shell, regardless of the complexity of the expression passed to bash -c ...?

(I could append something like ; echo; to my actual command and that "works", but I'd rather not. Is there a more proper way to force the intermediate process into existence?)

(edit: typo in ps output; removed sh tag as suggested in comments; one more typo)

Cognizance answered 15/6, 2017 at 20:47 Comment(4)
Why would you not want this optimization when it's possible?Koel
Mostly ensure consistent behavior when dealing with child processes in an environment where users can pass in arbitrary commands. I am not sure bypassing this optimization is my solution (the actual problem I'm having has to deal with a change of sudo: stackoverflow.com/a/34376188). But this behavior was interesting and I wanted to learn more about it.Cognizance
Great question. Did you mean to say \_ bash -c sleep 10 && echo instead of \_ bash -c sleep 10 && sleep 10 on line 2 of your first ps tree?Eton
@Eton indeed, thanksCognizance
K
9

There's actually a comment in the bash source that describes much of the rationale for this feature:

/* If this is a simple command, tell execute_disk_command that it
   might be able to get away without forking and simply exec.
   This means things like ( sleep 10 ) will only cause one fork.
   If we're timing the command or inverting its return value, however,
   we cannot do this optimization. */
if ((user_subshell || user_coproc) && (tcom->type == cm_simple || tcom->type == cm_subshell) &&
    ((tcom->flags & CMD_TIME_PIPELINE) == 0) &&
    ((tcom->flags & CMD_INVERT_RETURN) == 0))
  {
    tcom->flags |= CMD_NO_FORK;
    if (tcom->type == cm_simple)
      tcom->value.Simple->flags |= CMD_NO_FORK;
  }

In the bash -c '...' case, the CMD_NO_FORK flag is set when determined by the should_suppress_fork function in builtins/evalstring.c.

It is always to your benefit to let the shell do this. It only happens when:

  • Input is from a hardcoded string, and the shell is at the last command in that string.
  • There are no further commands, traps, hooks, etc. to be run after the command is complete.
  • The exit status does not need to be inverted or otherwise modified.
  • No redirections need to be backed out.

This saves memory, causes the startup time of the process to be slightly faster (since it doesn't need to be forked), and ensures that signals delivered to your PID go direct to the process you're running, making it possible for the parent of sh -c 'sleep 10' to determine exactly which signal killed sleep, should it in fact be killed by a signal.

However, if for some reason you want to inhibit it, you need but set a trap -- any trap will do:

# run the noop command (:) at exit
bash -c 'trap : EXIT; sleep 10'
Koel answered 15/6, 2017 at 21:1 Comment(5)
I can't tell from the source, but it seems to me Bash only does that if there is only one command on the whole -c command line. So something like bash -c ":; sleep 10" keeps the shell running even after it starts the sleep.Brandy
@ilkkachu, my intuitive expectation is that that's likely to vary by version -- 3.2 is going to be prior to the late-2014 implementation of should_suppress_fork, f/e. And even if it's the case today, I wouldn't expect it to still be true in future releases (which hopefully would resolve the missing opportunity for optimization) -- whereas a trap will always necessitate keeping the shell running.Koel
Seems to act similarly in both 3.2 and 4.4.Brandy
nod. Useful to know for current behavior, but I'm still skeptical that it's trustworthy going forward. If one can make a script run that much faster (granted, only a few ms in typical cases, but still) by having an implicit exec for its last command, why would Chet ever turn down that patch if someone were to write it?Koel
Thank you. In case you are wondering why I want to force that middle shell to exist in all cases, then answer is related to the change in sudo mentioned here: stackoverflow.com/a/34376188. A kill to sudo used to be forwarded to its children, but it's no longer the case, so I am trying to spawn my processes in a different process group.Cognizance

© 2022 - 2024 — McMap. All rights reserved.