Handle State Library

Location

  • config/handle_state.sh

Purpose

This library provides two core capabilities for Bash libraries:

  • Persisting local variable state from one function to another (typically initialization to cleanup) via a generated snippet that can be eval’d.

  • As its state persistence functions output code on stdout, the library allows provides the means to display messages to stdout from within initialization functions using a logging FIFO and background reader process.

Quick Start

Source the file once, then use hs_persist_state in the init function and eval the state in cleanup.

# Source once in the main script of your library
source "$(dirname "$0")/config/handle_state.sh"

init_function() {
    # Direct output to stdout would mess up the state snippet, so use hs_echo if needed
    hs_echo "Initializing..."
    # Define some opaque library resources
    local temp_file="/tmp/some_temp_file"
    local resource_id="resource_123"
    hs_persist_state temp_file resource_id
}

cleanup() {
    local temp_file resource_id
    eval "$1"
    rm -f "$temp_file"
    echo "Cleaned up resource: $resource_id"
}

# Capture the opaque state snippet emitted on stdout
state=$(init_function)
# Your main script logic here
cleanup "$state"

Public API

hs_setup_output_to_stdout

Sets up a FIFO and background reader process that forwards log lines from subshells to the main script output. The function initializes internal state, creates hs_cleanup_output, and defines hs_echo for use inside subshells.

  • Behavior: no-op if logging is already set up (detected via hs_get_pid_of_subshell).

  • Side effects: creates a FIFO via a temporary file, opens it on a file descriptor, and removes the FIFO path while the descriptor remains open.

    Warning

    This library allocates a new, unused file descriptor for internal job operations. While this descriptor will not interfere with any file descriptors already in use, please note that file descriptors are a global resource. The library will break down if downstream code attempts to use or manipulate its private file descriptor.

hs_cleanup_output

Defined dynamically by hs_setup_output_to_stdout. Sends the kill token to the FIFO, waits for the background reader to exit, and redefines itself as a no-op while resetting hs_echo to a plain echo.

hs_echo

Defined dynamically by hs_setup_output_to_stdout. Writes messages to the FIFO so they appear in the main script stdout even when stdout of a subshell is being captured.

  • Usage: hs_echo “message”

  • Notes: preserves Bash echo argument concatenation behavior.

hs_persist_state

Emits Bash code that restores specified local variables in a receiving scope. The emitted snippet only assigns values if the target variable is declared local in the receiving scope and is still empty.

The function accepts an optional -s “$state” argument to provide an existing state snippet. Libraries are encouraged to provide the same option to their initialization functions to allow callers to chain state snippets together.

When appending to an existing state snippet, the function checks for name collisions and refuses to overwrite existing variables. Some library combinations can be incompatible with the chaining approach because they use overlapping variable names. The alternate solution is to keep and eval separate state snippets for each library.

  • Usage: hs_persist_state [-s “$state”] var1 var2 …

  • Output: a string of Bash code intended to be eval’d by the caller.

  • Errors: - Refuses to persist reserved names __var_name and __existing_state. - Rejects collisions when a variable already exists in the provided state.

hs_read_persisted_state

This function is a simple wrapper around echo. There is no way in Bash to set variables in the caller scope without eval`ing code, so this function is only provided for symmetry with `hs_persist_state. Its output must be eval’d by the caller to restore state anyway and the effect is identical to eval “$state”. Code readability is slightly improved by using this function.

  • Usage: eval “$(hs_read_persisted_state “$state”)”

hs_get_pid_of_subshell

Parses the hs_cleanup_output function definition to extract the background reader PID. This is used to detect whether logging setup has already occurred.

Error Codes

  • HS_ERR_RESERVED_VAR_NAME=1: a reserved variable name was passed to hs_persist_state.

  • HS_ERR_VAR_NAME_COLLISION=2: the requested variable was already defined in the state string when persisting.

Behavior Details

Logging FIFO

When sourced, the library calls hs_setup_output_to_stdout automatically. It spawns a background reader that:

  • reads lines from the FIFO with a timeout loop,

  • echoes them to stdout,

  • exits after a magic kill token or an idle timeout,

  • closes the FIFO descriptor before exiting.

State Persistence

hs_persist_state captures caller-local variables by name, embeds their values in a guarded assignment snippet, and prints that snippet. The guards ensure that only local variables are populated in the receiving scope and that non-empty locals are not overwritten.

Caveats

  • Always declare target variables local before eval’ing the state snippet; otherwise assignments are skipped to avoid leaking globals.

  • The library uses eval internally; treat state strings as trusted input.

  • Call hs_cleanup_output when you are done to stop the background reader.

Source Listing

  1#!/bin/bash
  2# File: config/handle_state.sh
  3# Description: Helper functions to carry state information between initialization and cleanup functions.
  4# Author: Jean-Marc Le Peuvédic (https://calcool.ai)
  5
  6# Sentinel
  7[[ -z ${__HANDLE_STATE_SH_INCLUDED:-} ]] && __HANDLE_STATE_SH_INCLUDED=1 || return 0
  8
  9# Library usage:
 10#   In an initialization function, call hs_persist_state with the names of local variables
 11#   that need to be preserved for later use in a cleanup function.
 12# Example:
 13#   init_function() {
 14#       local temp_file="/tmp/some_temp_file"
 15#       local resource_id="resource_123"
 16#       hs_persist_state temp_file resource_id
 17#       exit 0
 18#   }
 19#   cleanup() {
 20#       local temp_file
 21#       local resource_id
 22#       eval "$1"  # Recreate local variables from the state string
 23#       # Now temp_file and resource_id are available for cleanup operations
 24#       rm -f "$temp_file"
 25#       echo "Cleaned up resource: $resource_id"
 26#   }
 27#
 28# Upper level usage: state=$(init_function)
 29#                    cleanup "$state"
 30
 31# --- logging from $(command) using a FIFO ---------------------------------------
 32# This section sets up a FIFO and a background reader process to allow functions
 33# to log messages to the main script's stdout/stderr even when they are called
 34# from subshells (e.g., inside `$(...)` command substitutions). Functions can
 35# use `hs_echo "message"` to send messages to the main script's output.
 36
 37# Function: 
 38#   _hs_set_up_logging
 39# Description:
 40#   Sets up a FIFO and background reader process for logging.
 41#   The background reader must start before any I/O redirection occurs. This
 42#   is why it is called at the end of this file, so that when this file is sourced,
 43#   the logging is set up.
 44# Usage:
 45#   Do not call directly; called automatically when this file is sourced.
 46#   When done with the library, call `hs_cleanup_output` to terminate the background reader
 47#   or else a 5 seconds idle timeout will occur.
 48hs_setup_output_to_stdout() {
 49    # Test if already set up
 50    if hs_get_pid_of_subshell >/dev/null 2>&1; then
 51        echo "[WARN] hs_setup_output_to_stdout: already set up; skipping." >&2
 52        return 0
 53    fi
 54    # Create a FIFO using a proper temporary file and file descriptor 3 
 55    fifo_file=$(mktemp -u)
 56    mkfifo "$fifo_file"
 57    # Redirect fd 3 into the FIFO
 58    exec {_hs_fifo_fd}<> "$fifo_file"
 59    # Make file disappear immediately. The FIFO remains accessible via fd 3.
 60    rm "$fifo_file"
 61    # Kill token
 62    _hs_fifo_kill_token="hs_kill_${$}_$RANDOM_$RANDOM"
 63    _hs_fifo_idle_limit=5  # seconds before self-termination
 64
 65    # Run a background task that reads from the FIFO and displays messages
 66    (
 67        idle=0
 68
 69        while true; do
 70            line=''
 71
 72            if IFS= read -t 1 -r line <&"${_hs_fifo_fd}"; then
 73                # Received a line; reset idle counter
 74                idle=0
 75                # Self-terminate on the exact magic token
 76                if [ "${line:-}" = "${_hs_fifo_kill_token}" ]; then
 77                    break
 78                fi
 79                echo "$line"
 80            else
 81                # read timed out or encountered EOF/error - increment idle counter
 82                idle=$((idle + 1))
 83                if [ "$idle" -ge "$_hs_fifo_idle_limit" ]; then
 84                    break
 85                fi
 86                # continue to wait
 87            fi
 88        done
 89        # Dismantle FIFO: close fd
 90        exec {_hs_fifo_fd}>&-
 91    ) & _hs_fifo_reader_pid=$!
 92 
 93    # Function:
 94    #   hs_cleanup_output, or redefined globally with the kill token embedded.
 95    # Description:
 96    #   Sends the magic kill token to the logging FIFO to terminate the background reader.
 97    #   Waits for the background reader to exit and redefines itself to a no-op.
 98    #   Redefines hs_echo to a simple echo.
 99    # Parameters:
100    #   None
101    printf -v _hs_qtoken '%q' "$_hs_fifo_kill_token"
102    eval "hs_cleanup_output() {
103        if hs_echo $_hs_qtoken ; then
104            wait $_hs_fifo_reader_pid 2>/dev/null
105            hs_cleanup_output() { :; }
106            hs_echo() { echo \"\$*\" ; }
107        fi
108        return 0
109    }"
110        
111    # Function:
112    #   hs_echo
113    # Description:
114    #   Writes messages to the logging FIFO for display in the main script's stdout.
115    #   Specifically designed to work inside subshells called via `$(...)`.
116    #   Mimic Bash echo argument concatenation behavior.
117    #   Here IFS acts on "$*" expansion to insert spaces between arguments.
118    # Arguments:
119    #   $* - echo options -neE or message parts to echo
120    eval "hs_echo() {
121        IFS=\" \" echo \"\$*\" >&\"${_hs_fifo_fd}\"
122    }"
123}
124
125# --- Public error codes --------------------------------------------------------
126readonly HS_ERR_RESERVED_VAR_NAME=1
127readonly HS_ERR_VAR_NAME_COLLISION=2
128
129# --- hs_persist_state ----------------------------------------------------------
130# Function:
131#   hs_persist_state
132# Description:
133#   Emits a bash code snippet that, when eval'd in the receiving scope,
134#   will recreate the specified local variables with their current values.
135#   The emitted code checks if the variable is declared `local` in the receiving
136#   scope before assigning to it, to avoid polluting global scope.
137#   If the variable already exists and is non-empty in the receiving scope,
138#   an error message is printed and the assignment is skipped.
139# Arguments:
140#   -s <state> - optional; if provided, appends the emitted code to variable
141#                definitions found in <state> (bash code snippet).
142#   $@ - names of local variables to persist.
143# Usage examples:
144#   # direct eval
145#   state=$(hs_persist_state var1 var2)
146#   cleanup() {
147#       local var1 var2
148#       eval "$1"
149#       # vars are available here
150#   }
151hs_persist_state() {
152    # Read optional -s <state> argument
153    local __existing_state=""
154    if [ "${1:-}" = "-s" ]; then
155        shift
156        __existing_state="$1"
157        shift
158        # Emit existing state first
159        printf '%s\n' "$__existing_state"
160    fi
161    local __var_name
162    for __var_name in "$@"; do
163        # Check that the value of __var_name is neither "__var_name" nor "__existing_state"
164        if [ "$__var_name" = "__var_name" ] || [ "$__var_name" = "__existing_state" ]; then
165            echo "[ERROR] hs_persist_state: refusing to persist reserved variable name '$__var_name'." >&2  
166            return "$HS_ERR_RESERVED_VAR_NAME"
167        fi
168        # In a subshell, declare "$__var_name" as local to capture its value and
169        # attempt to restore it from "$__existing_state".
170        (
171            local "$__var_name"
172            eval "$__existing_state"
173            # Check if the variable pointed to by __var_name has been initialized
174            if ! [ -z "${!__var_name+x}" ]; then
175                echo "[ERROR] hs_persist_state: variable '$__var_name' is already defined in the state, with value '${!__var_name}'." >&2
176                return 1
177            fi
178        ) || return "$HS_ERR_VAR_NAME_COLLISION"
179        # Check if the variable exists in the caller (local or global). We avoid
180        # using `local -p` here because that only inspects locals of this
181        # function, not the caller's scope. If the variable exists, capture its
182        # value and emit a guarded assignment that will only set it in the
183        # receiving scope if that scope has declared it `local`.
184        if [ "${!__var_name+x}" ]; then
185            # Get the value of the variable
186            local var_value
187            eval "var_value=\"\${$__var_name}\"" || eval "var_value=\"\$$__var_name\""
188            # Emit a snippet that, when eval'd in the receiving scope, will
189            # restore the existing, empty local variables from the saved state.
190            printf "
191if local -p %s >/dev/null 2>&1; then
192  if [ -n \"\${%s+x}\" ] && [ -n \"\${%1s}\" ]; then
193    printf \"[ERROR] local %1s already defined; refusing to overwrite\\n\" >&2
194    return 1
195  else
196    %s=%q
197  fi
198fi
199" "$__var_name" "$__var_name" "$__var_name" "$__var_name" "$__var_name" "$var_value"
200        fi
201    done
202}
203
204# --- hs_read_persisted_state --------------------------------------------------------
205# Function: 
206#   hs_read_persisted_state
207# Description: 
208#   Emits the state string produced by `hs_persist_state` without
209#   evaluating it. This avoids executing the state inside this function's scope
210#   (which would prevent assignments to `local` variables declared in the
211#   calling function). Callers should `eval "$(hs_read_persisted_state "$state")"`
212#   or simply `eval "$state"` to recreate variables in the caller scope.
213#   Can be called several times to extract distinct variables.
214#   "$state" is a bash code snippet that assigns values to existing local and empty
215#   variables in the current scope.
216# Arguments:
217#   $1 - state string produced by `hs_persist_state`
218# Usage examples:
219#   # direct eval
220#   cleanup() {
221#       local temp_file resource_id
222#       eval "$1"
223#       # vars are available here
224#   }
225#
226#   # helper wrapper form (prints state; caller evals it in its own scope)
227#   cleanup() {
228#       local state="$1"
229#       local temp_file resource_id
230#       eval "$(hs_read_persisted_state \"$state\")"
231#   }
232hs_read_persisted_state() {
233    local state_string="$1"
234    printf '%s' "$state_string"
235}
236
237# --- Utility functions --------------------------------------------------------
238# Function:
239#    hs_get_pid_of_subshell
240# Description:
241#    Returns the PID of the current subshell that works in conjunction with hs_echo to
242#    ensure output is properly captured and redirected to whatever stdout was when the
243#    library was sourced.
244# Usage:
245#    pid=$(hs_get_pid_of_subshell)
246# Return status:
247#    0 - Success
248#    1 - Internal error: hs_cleanup_output not defined or doesn't have the expected format.
249hs_get_pid_of_subshell() {
250    # Extract the PID of the background reader process from the function definition
251    local func_def
252    func_def=$(declare -f hs_cleanup_output)
253    local pid
254    pid=${func_def##*wait }
255    # The above string substitution will just return $func_det without an error if "wait " is not found.
256    if [ "$pid" = "$func_def" ]; then
257        echo "hs_cleanup_output function not found or has unexpected format" >&2
258        return 1
259    fi
260    pid=${pid%%[^0-9]*}
261    printf '%s' "$pid"
262}
263
264# Initialize logging when the script is sourced
265hs_setup_output_to_stdout
266
267# Note: Remember to call hs_cleanup at the end of your main script to clean up resources.