Exiting from a subshell does not end your shell script

This is part of the Semicolon&Sons Code Diary - consisting of lessons learned on the job. You're in the bash category.

Last Updated: 2024-04-19

I came across something that surprised me when writing a shell script that was supposed to exit early. Look at this code:

fail() {
  echo "Fail"
  exit 1
}

main() {
  cat Gemfile | while read -r line; do
    echo "$line"
    fail
  done

  echo "I should not be executed"
  exit 0
}

When I ran this, the echoing of the output from the Gemfile stopped after the first line (as expected) but I also saw the output 'I should not be executed.', which was not expected.

By comparison, this issue did not happen here:

main() {
  fail

  echo "I should not be executed"
  exit 0
}

main

What's the difference?

The pipe operator spawned the while loop in a subshell and the exit 1 in the fail function only affects this subshell. So the looping stops, but not the parent shell.

How to fix this?

The easiest way is to avoid a subshell by using redirection instead:

This behaves as expected

  while read -r line < Gemfile; do
    echo "$line"
    fail
  done

How can you avoid a subshell when you need to execute a function?

i.e. How would you rewrite the following?

git diff --cached --name-only | while read -r line; do
  ...
done

We would get the output using command substitution:

changed_files() {
  git diff --cached --name-only
}

main() {
  for file in $(changed_files); do
    echo "$file"
    check_for_debuggers "$file"
  done

  echo "I should not be executed"
  exit 0
}