Infrastructure

The Script That Dies Quietly: `set -e`, Shell Arithmetic, and the Contracts Your Automation Owes You

Infrastructure estates are full of small shell scripts: glue between systems, aggregation jobs, smoke checks. They are usually the least-reviewed code in the estate, and their failures are easy to miss because a partial run can look exactly

Infrastructure estates are full of small shell scripts: glue between systems, aggregation jobs, smoke checks. They are usually the least-reviewed code in the estate, and their failures are easy to miss because a partial run can look exactly like a full one. This article walks one specific failure mode from an internal engineering review — a fifteen-line counting script that aborts mid-loop under set -e with no error message — and draws out the decision path for a CTO, head of infrastructure, or security lead. The one-line fix matters less than the class of bug it represents and the contracts the script was violating.

A small script, a silent failure

The script under review counts occurrences of the four nucleotides (A, C, G, T) in a string, rejects anything else, and prints a tally. It starts with set -euo pipefail, keeps counts in an associative array, and loops character by character:

for (( i=0; i<${#input}; i++ )); do
                char="${input:$i:1}"
                (( counts["$char"]++ ))
            done
            

Run against real input, it dies after its first increment. No message, no traceback — the bash -x trace shows the loop reaching the increment and stopping. The mechanism is a classic shell footgun:

The script's first piece of real work kills it. That is the "dies quietly" class of bug: not a crash with a traceback, not a loud validation error — the script simply stops, and downstream stages cannot tell a partial run from a complete one.

Illustrative scenario (fictional — not a Validus/Veltiosi client engagement). A platform team notices a small aggregation job in the nightly pipeline failing intermittently. Run by hand, it prints a few lines and exits; in CI it stops after the first line with no message. bash -x shows the loop incrementing a counter and the script terminating on the first increment. The root cause is the arithmetic command above — not the data, the network, or the scheduler.

Diagnose before you touch anything

Establish what the script actually does before changing a line. All of this is read-only:

  1. Trace it. bash -x script.sh "GATTACA" prints every command before it runs. The last command in the trace is where the script stopped; the arithmetic command's exit status is the reason.
  2. Isolate the suspect line. Run the arithmetic in a throwaway subshell: bash -c 'set -e; x=0; (( x++ )); echo survived' prints nothing — the script died exactly there. Run bash -c 'set -e; x=0; (( x += 1 )); echo survived' and it prints survived. The difference between those two lines is the whole bug.
  3. Confirm the environment, not just the laptop. Bash behavior varies by version: ${var^^} requires Bash 4+, and set -u interacts with unset associative-array keys differently across versions. Run bash --version on the hosts that actually execute the script.

The diagnostic principle: separate "the script failed", "the script produced wrong output", and "the script stopped without saying why". An exit code answers the first; bash -x answers the third. Never theorize about a shell failure you haven't traced.

The decision path: trade-offs, not magic

Four defensible fixes, with different trade-offs:

  1. (( counts["$char"] += 1 )) — the expression now evaluates to the new count, which cannot be 0 here, so the command exits 0. Minimal diff, but it fixes this line, not the class: any arithmetic expression that can evaluate to zero will still kill the script.
  2. Assignment form — counts["$char"]=$(( counts["$char"] + 1 )) — the assignment succeeds regardless of the computed value, so a value of 0 can never abort the script. This decouples arithmetic from exit-status checking, which is the contract you actually want. Slightly more verbose, slightly more correct.
  3. (( ... )) || true — explicitly neutralizes the exit status where you genuinely don't care about it. Self-documenting, but easy to overuse into masking real failures; use it sparingly and comment why.
  4. Restructure. For a fixed key domain, plain counters or a case statement avoid associative arrays entirely: no iteration-order questions, fewer Bash-version surprises. Associative arrays earn their keep only when keys are dynamic.

Decision rule: in critical paths, choose the fix that removes the failure class (option 2 or 4), not the one that silences the symptom (option 1, sometimes 3). And validate input once, up front — uppercase the whole string with a single expansion and reject early — rather than re-validating every character mid-loop.

Changing a script that runs in production is a deployment, not an edit. Branch it, run the existing test suite, diff before/after output on representative inputs, then roll out. Rollback is a revert — which only exists if the script is in version control and the change is small.

Output and exit codes are contracts

The review surfaced three contract violations that matter more than the arithmetic bug:

For a security lead: if a script handles credentials, source them from the environment or a secret manager — never embed them in the script or on a command line. A script that fails loudly is easier to audit than one that fails silently: the exit code and the trace are the audit trail.

Operator checklist: hardening shell automation

Need help planning a staged migration?

Validus helps teams reduce lock-in and modernize infrastructure without disruptive big-bang change.

Talk to Validus