4

Normally, when a job is launched in the background, jobs will report that it is finished the first time it is run after the job's completion, and nothing for subsequent executions:

$ ping -c 4 localhost &>/dev/null &
[1] 9666
$ jobs
[1]+  Running                 ping -c 4 localhost &> /dev/null &
$ jobs
[1]+  Done                    ping -c 4 localhost &> /dev/null
$ jobs  ## returns nothing
$ 

However, when run in a subshell within a script it seems to always return a value. This script will never exit:

#!/usr/bin/env bash
ping -c 3 localhost &>/dev/null &
while [[ -n $(jobs) ]]; do
    sleep 1; 
done

If I use tee in the [[ ]] construct to see the output of jobs, I see that it is always printing the Done ... line. Not only once as I expected but, apparently, for ever.

What is even stranger is that running jobs within the loop causes it to exit as expected:

#!/usr/bin/env bash
ping -c 3 localhost &>/dev/null &
while [[ -n $(jobs) ]]; do
    jobs
    sleep 1; 
done

Finally, as pointed out by @mury, the first script works as expected and exits if run from the commandline:

$ ping -c 5 localhost &>/dev/null & 
[1] 13703
$ while [[ -n $(jobs) ]]; do echo -n . ; sleep 1; done
...[1]+  Done                    ping -c 5 localhost &> /dev/null
$ 

This came up when I was answering a question on Super User so please don't post answers recommending better ways of doing what that loop does. I can think of a few myself. What I am curious about is

  1. Why does jobs act differently within the [[ ]] construct? Why will it always return the Done... line while it doesn't when run manually?

  2. Why does running jobs within the loop change the behavior of the script?

  • It's not the subshell that does this, but being run in a script. When I ran while [[ -n $(jobs) ]] .. in an interactive shell, it worked fine. – muru Mar 24 '15 at 11:39
  • @muru thanks, I'll clarify that, but the question still stands. Why does it behave this way when run in a script and why does including jobs in the loop fix it? Also, it works as expected even in a script if it is not run in a subshell. If you save the output in a variable and test that variable, for example. – terdon Mar 24 '15 at 11:47
  • @iyrin as I pointed out in the question, I am not looking for solutions. I can think of several ways to do this better. I am trying to understand the workings of jobs and why it behaves this way in this setting. – terdon Mar 24 '15 at 13:17
6

You know, of course, that $(…) causes the command(s) within the parentheses to run in a subshell.  And you know, of course, that jobs is a shell builtin.  Well, it looks like jobs clears a job from the shell’s memory once its death has been reported.  But, when you run $(jobs), the jobs command runs in a subshell, so it doesn’t get a chance to tell the parent shell (the one that’s running the script) that the death of the background job (ping, in your example) has been reported.  So, each time the shell spawns a subshell to run the $(jobs) thingie, that subshell still has a complete list of jobs (i.e., the ping job is there, even though it’s dead after the 5th iteration), and so jobs still (again) believes that it needs to report on the status of the ping job (even if it’s been dead for the past four seconds).

This explains why running an unadulterated jobs command within the loop causes it to exit as expected: once you run jobs in the parent shell, the parent shell knows that the job’s termination has been reported to the user.

Why is it different in the interactive shell?  Because, whenever a foreground child of an interactive shell terminates, the shell reports on any background jobs that have terminated1 while the foreground process was running.  So, the ping terminates while the sleep 1 is running, and when the sleep terminates, the shell reports on the background job’s death.  Et voilà.


1 It might be more accurate to say “any background jobs that have changed state while the foreground process was running.”  I believe that it might also report on jobs that have been suspended (kill -SUSP, the programmatic equivalent to Ctrl+Z) or become unsuspended (kill -CONT, which is what the fg command does).

  • OK, this is helping me understand. However, I don't get how the jobs run within the while loop will affect the $(jobs). By your reasoning, the $(jobs) shouldn't have access to the information of the parent shell. Why does running jobs within the loop affect that? Is there some sort of central repository where bash saves the running jobs and that is only updated when run outside a subshell? Isn't the loop running in its own subshell anyway? – terdon Mar 24 '15 at 14:26
  • 2
    Because running jobs directly in the shell that's running the script (the "parent shell") changes the information in the parent shell's memory. And subsequent invocations of $(jobs) are effected by a fork (and not an exec) of that parent shell, resulting in a child shell process that has a copy of the parent shell's memory at the time of the fork. – Scott Mar 24 '15 at 14:35
  • @terdon The loop is not running in the subshell. $(jobs) creates a subshell just to run the jobs built-in command. That subshell replicates the main bash process at the time it's started, i.e. each time it's starting to execute [[ -n $(jobs) ]]. – Gilles Mar 24 '15 at 23:18

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.