Preventing Infinite Recursion in Bash Completion Wrappers

Summary

A developer attempted to extend an existing bash completion function (foo) by wrapping the original function inside a new one. While this worked during the initial shell session, re-sourcing the configuration file caused an infinite recursion loop, leading to a stack overflow and a session crash. This occurs because the mechanism used to “capture” the original function fails to account for the fact that the function being captured is the newly wrapped version after the second source command.

Root Cause

The failure stems from a circular dependency created during the shell’s state mutation.

  • First Source: The script captures the original fzf completion function and stores it in __orig_foo_completion_func. It then defines _foo_comp and maps it to foo.
  • The Mutation: The variable __orig_foo_completion_func now holds the name of the “real” underlying function.
  • Second Source: When ~/.bashrc is sourced again, the line __orig_foo_completion_func=$(...) executes. However, complete -p foo now returns the definition for the wrapper (_foo_comp), not the original function.
  • The Loop: The script extracts _foo_comp and assigns it to __orig_foo_completion_func. Now, when _foo_comp is called, it calls __orig_foo_completion_func, which is itself _foo_comp.

Why This Happens in Real Systems

In complex production environments, this is a classic idempotency failure.

  • Non-Idempotent Scripts: Most shell configuration scripts are written with the assumption they will run exactly once.
  • Stateful Side Effects: Functions like complete -p or alias modify the global environment. If a script modifies a global state and then attempts to read that same state to perform its logic, it is no longer “pure.”
  • Namespace Pollution: Using global variables like __orig_foo_completion_func without checking if they already exist or if they point to the expected implementation leads to state corruption over multiple execution cycles.

Real-World Impact

  • Developer Friction: Engineers spend hours debugging “ghost” issues that only appear after they reload their environment.
  • CI/CD Failures: If shell wrappers are used in automated provisioning or deployment scripts, a non-idempotent script can cause a blocking failure in the build pipeline.
  • System Instability: In extreme cases, recursive shell expansions can consume high CPU or memory before the process terminates, potentially affecting other processes in a shared environment.

Example or Code (if necessary and relevant)

# The problematic pattern:
# 1. Capture current state
__orig_func=$(complete -p my_cmd | awk '{print $NF}')

# 2. Define new wrapper
my_cmd_wrapper() {
    "$__orig_func" "$@"
    COMPREPLY+=("extra_arg")
}

# 3. Re-bind
complete -F my_cmd_wrapper my_cmd

# BUG: If sourced again, __orig_func becomes 'my_cmd_wrapper'
# Result: my_cmd_wrapper -> calls __orig_func (which is my_cmd_wrapper) -> Loop

How Senior Engineers Fix It

Senior engineers focus on idempotency and guard clauses. To fix this, we must ensure the “capture” logic only executes if the function hasn’t already been wrapped.

  • Guard Clauses: Check if the wrapper is already present before attempting to re-wrap.
  • Unique Namespacing: Use a specific naming convention to distinguish between “original” and “wrapped” functions.
  • Deterministic State: Instead of parsing the current completion to find the old function, use a hardcoded or uniquely identified “base” function.

The Correct Implementation:

# Check if we have already wrapped this function to prevent recursion
if [[ "$(complete -p foo 2>/dev/null)" != *"_foo_comp"* ]]; then
    # Capture the original logic
    __orig_foo_completion_func=$(complete -p foo 2>/dev/null | sed -r 's/.*\s+-F\s+([a-zA-Z_]+)\s+[a-zA-Z_]+/\1/')

    _foo_comp() {
        # Call the original captured function
        "$__orig_foo_completion_func" "$@"
        # Append new completions
        COMPREPLY+=($(compgen -W '--bar' -- "${COMP_WORDS[COMP_CWORD]}"))
    }
    complete -F _foo_comp foo
fi

Why Juniors Miss It

  • Happy Path Bias: Juniors often test their code by opening a new terminal (clean state), verifying it works, and assuming it is correct. They rarely test by re-sourcing the environment.
  • Mental Model Gap: They view source as a way to “apply changes” rather than a way to “re-execute a sequence of mutations on a living state.”
  • Lack of Idempotency Awareness: They focus on the functionality (adding --bar) rather than the lifecycle of the configuration (how the script behaves on the 2nd, 3rd, or 100th execution).

Leave a Comment