bash set -e problems – ai conversation

In Bash, using standard command substitution (e.g., VAR=$(my_func)) while set -e is active can be problematic because the main script might not exit immediately if the subshell where the function runs fails. To return a value to a variable while strictly adhering to set -e behavior, use a nameref or a global variable

Option 1: Nameref (Recommended for Bash 4.3+) 

This method passes the name of the destination variable to the function. It modifies the variable in the current shell, so any failure inside the function will trigger set -e in the main script. 

#!/bin/bash
set -e

# Function using nameref (-n) to return value to a variable
get_data() {
    local -n dest_var=$1
    # If any command fails here, set -e will exit the whole script
    dest_var="Returned Value"
}

my_result=""
get_data my_result
echo "$my_result"
  • Why it works: It avoids subshells.
  • set -e compatibility: If a command fails inside get_data, the script terminates immediately. 

Option 2: Global Variable

Modifying a global variable directly is the simplest way to “return” data without a subshell. 

#!/bin/bash
set -e

get_data_global() {
    # Any failure here triggers the script's set -e
    GLOBAL_RESULT="Success"
}

get_data_global
echo "$GLOBAL_RESULT"

Option 3: Command Substitution with Explicit Check

If you must use command substitution, you should explicitly check the result to ensure a failure in the subshell correctly terminates the main script. 

#!/bin/bash
set -e

get_data_subshell() {
    echo "Subshell Result"
    return 1 # Simulating an error
}

# The "|| exit" ensures set -e behavior is preserved manually
my_result=$(get_data_subshell) || exit $?
echo "$my_result"

Summary of Differences

Method Uses Subshell?set -e SafetyNotes
NamerefNoHighBest practice for modern Bash (4.3+).
Global VarNoHighSimple, but can lead to messy code.
$(...)YesLowRequires manual 

In Bash, set -e and set -u are safety flags used to make scripts more robust by “failing fast” when something goes wrong.

Quick Comparison Table

Feature set -e (errexit)set -u (nounset)
PurposeExit immediately if a command fails (returns a non-zero status).Exit immediately if you try to use an uninitialized variable.
Protects againstSilent command failures that could lead to data corruption.Typos in variable names or missing environment variables.
Classic Examplecd /missing_dir && rm -rf * (Stops if cd fails)rm -rf "$UNSET_VAR/*" (Stops before deleting root)

1. set -e (Exit on Error)

When enabled, the shell stops executing the script as soon as any command returns a non-zero exit code. 

  • Why use it: It prevents the script from continuing if a critical step fails.
  • The “gotcha”: It does not trigger for commands in a pipeline (unless set -o pipefail is also used) or commands that are part of a conditional (like if or while statements). 

2. set -u (Exit on Unset Variable)

When enabled, the shell treats an attempt to expand a variable that hasn’t been defined as an error. 

  • Why use it: It is the best defense against typos. For example, if you define BACKUP_DIR but accidentally type echo $BAKUP_DIR, the script will crash instead of printing an empty line.
  • Bypassing it: If you want a variable to potentially be empty, use the ${VAR:-default} syntax to provide a fallback value. 

Best Practice: The “Bash Header”

Modern 2026 standards, including the Google Bash Style Guide, recommend combining these at the top of every script to ensure maximum safety: 

#!/bin/bash
set -euo pipefail
  • -e: Exit on command failure.
  • -u: Exit on unset variables.
  • -o pipefail: Ensures the script fails if any command in a pipeline fails (e.g., command_that_fails | true will now correctly trigger an exit). 

In Bash, a standard heredoc (<<EOFcannot be indented with spaces. If the closing EOF is not at the very beginning of the line (i.e., if it has even one space before it), Bash will not recognize it as the end of the document and will continue reading, likely causing your script to fail. 

However, you can achieve your goal using the following methods:

1. The <<- Operator (Tabs Only) 

If you change the operator to <<-, Bash will strip all leading tab characters (not spaces) from every line, including the line with the closing EOF. This is the standard way to indent heredocs while keeping your code readable. 

# Use a hyphen to allow TAB indentation
cat <<-EOF
	if [ "\$(docker inspect -f '{{.State.Status}}' $cid 2>/dev/null)" == "exited" ]; then
	    echo "Removing SAFE container: $name ($cid)"
	    docker rm $cid
	fi
	EOF
  • Warning: This only works with literal Tab characters. If your text editor is set to “expand tabs to spaces,” this will fail. 

2. Manual Indentation Stripping (Spaces Allowed)

If you prefer using spaces for indentation (as recommended by the Google Bash Style Guide), you can pipe the heredoc through sed to remove the leading spaces manually. 

# Removes 4 leading spaces from every line
cat <<EOF | sed 's/^    //'
    if [ "\$(docker inspect -f '{{.State.Status}}' $cid 2>/dev/null)" == "exited" ]; then
        echo "Removing SAFE container: $name ($cid)"
        docker rm $cid
    fi
EOF
  • Note: In this version, the closing EOF must still be at the beginning of the line with zero spaces before it. 

Summary of Rules

  • <<EOF: Closing delimiter must be at the start of the line (column 0).
  • <<-EOF: Closing delimiter and content can be indented, but only with Tab characters.
  • Content: Any indentation you include inside the heredoc (like the echo inside your if block) will be preserved in the output. 

Recommendation: The “Generator Function” Pattern

Define a helper function that generates the required code block for a single container. This keeps your main loop uncluttered and makes the heredoc indentation easier to manage. 

#!/bin/bash

# Function to generate the removal snippet for one container
generate_removal_snippet() {
  local cid="$1"
  local name="$2"

  # We use <<EOF (unquoted) so $cid and $name are expanded NOW,
  # but we escape \$() so the docker inspect runs LATER when the script executes.
  cat <<EOF
if [ "\$(docker inspect -f '{{.State.Status}}' $cid 2>/dev/null)" == "exited" ]; then
  echo "Removing SAFE container: $name ($cid)"
  docker rm $cid
else
  echo "SKIP: Container $name ($cid) is no longer in 'exited' state."
fi
EOF
}

# --- Main Logic ---
for cid in "${SAFE[@]}"; do
  local name="${HASH[$cid,name]}"
  # Call the function to output the block
  generate_removal_snippet "$cid" "$name"
done

Why this is cleaner:

  • Separation of Concerns: The loop only manages which containers to process, while the function handles the template for the generated script.
  • Indentation Control: You can indent the function call inside your loop while keeping the cat <<EOF block at the left margin inside the function. This prevents “indentation drift” and ensures the closing EOF is always at column 0 as required.
  • Reuse: If you ever need to generate this same check elsewhere in your script, you can simply call the function again.
  • Stylistic Consistency: Following the Google Bash Style Guide, functions make it easier to follow the “no tabs” rule while still having indented, readable logic. 

Implementation Tip

If you are generating a full script to be piped into bash or saved to a file, remember to add a shebang at the very top of your output (outside the loop):

echo "#!/bin/bash"
# ... then run your loop to append the snippets ...

Leave a Reply