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.