Summary
This incident revolves around a Bash subshell side effect: when you wrap a function call in $(...), the function executes in a subshell, and any variables set inside it—including your captured PID—are lost when the subshell exits. The result is a mysteriously empty PID variable, even though the logic appears correct.
Root Cause
The root cause is subshell invocation caused by command substitution.
Key points:
$(showDialog ...)runs the function in a child process.- Variables set in a child process cannot propagate back to the parent shell.
$!inside a subshell refers to the PID of a background job in that subshell, not the parent.- When the subshell exits, all variable assignments disappear.
Why This Happens in Real Systems
Real-world shells behave this way because:
- Process isolation: subshells have their own PID namespace and environment.
- Command substitution semantics: designed to return output, not variable state.
- Bash 3.2 limitations: macOS ships an ancient Bash that lacks modern conveniences like
coproc.
These constraints make PID passing tricky unless you avoid subshells entirely.
Real-World Impact
This issue commonly causes:
- Lost PIDs, making it impossible to kill background processes later.
- Zombie dialogs or orphaned processes that persist after scripts finish.
- Race conditions when scripts assume a PID was captured but it wasn’t.
- Silent failures, because the script continues without obvious errors.
Example or Code (if necessary and relevant)
Below is a minimal, correct pattern for capturing the PID of an osascript dialog without invoking a subshell:
showDialog() {
local text="$1"
local seconds="$2"
IFS='' read -r -d '' js <<EOF
function run() {
const app = Application.currentApplication();
app.includeStandardAdditions = true;
app.displayDialog("$text", {
buttons: ["Close"],
defaultButton: "Close",
givingUpAfter: $seconds
});
}
EOF
osascript -l JavaScript -e "$js" &
dialog_pid=$!
}
And usage:
showDialog "Installing Microsoft Word..." 0
echo "Dialog PID is $dialog_pid"
# ... do work ...
kill "$dialog_pid"
How Senior Engineers Fix It
Experienced engineers avoid subshells and use patterns like:
- Never wrap a function call in
$(...)unless you need its stdout. - Return values via stdout, but assign variables in the parent shell.
- Capture PIDs immediately after backgrounding a process.
- Use global or higher-scope variables intentionally, not accidentally.
- Avoid unnecessary command substitution, especially around functions.
Typical fixes include:
- Calling the function directly:
showDialog "Hello" 0
instead of
$(showDialog "Hello" 0) - Storing the PID in a global variable the parent shell can read.
- Using FIFOs or temp files if interprocess communication is needed.
Why Juniors Miss It
Juniors often miss this because:
- Subshell behavior is invisible—the script “looks” like it should work.
- They assume functions behave like functions in other languages.
- They don’t yet know that
$(...)silently creates a new process. $!seems straightforward, but its scope is tied to the current shell.- macOS’s outdated Bash version makes debugging even more confusing.
The result is a classic early-career Bash pitfall: variables mysteriously disappearing due to subshell boundaries.