How do I return the PID of an osascript call from within a function (or how do I create a persistent notification I can kill later in the script)?

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.

Leave a Comment