Files
gstack/gstack-upgrade/migrations/v1.37.0.0.sh
Benjamin D. SmithandClaude Opus 5 4543b3c66b fix(scripts): stop heredoc bodies deadlocking under Homebrew bash
`./setup --help` can hang forever on macOS, printing nothing, with no way
to tell it apart from a slow install. Eleven scripts carry the same
latent hang, `setup` itself being the one every user hits first.

bash 5.2+ delivers a heredoc body of 64KiB or less through a pipe: the
forked child writes the entire body before exec, and nothing reads the
other end until the command starts. Under macOS pipe-KVA pressure the
kernel hands a fresh pipe a 512-byte buffer instead of the usual 16-64KiB,
so any body of 512 bytes or more blocks write() permanently. The capacity
check bash would need to notice (F_GETPIPE_SZ) is Linux-only, so it never
fires here. It is pressure-dependent, which is why it reads as "worked on
my machine" — the same script runs fine all day and then wedges.

Homebrew bash is what `#!/usr/bin/env bash` resolves to on a Mac with brew
on PATH, which is most of them. Apple's /bin/bash 3.2 predates the pipe
path and is unaffected, so the bug is invisible to anyone testing with the
system shell.

The fix is `BASH_COMPAT=50` in each affected script, which restores the
pre-5.2 tempfile path:

    $ bash -c 'probe() { [ -p /dev/stdin ] && echo PIPE || echo TEMPFILE; }
               probe <<EOF
    $(printf "x%.0s" $(seq 1 1000))
    EOF'
    PIPE
    $ BASH_COMPAT=50 bash -c '...same...'
    TEMPFILE

- Not a `#!/bin/bash` shebang swap: that pins the script to whatever bash
  lives at /bin (3.2 on macOS, absent on some Linux distributions) and is
  bypassed entirely by `bash script.sh` call sites. The variable survives
  both.
- Not exported, so child processes keep their own compat level.
- Placed below any `--help` sed range that reads $0, so usage output is
  unchanged (verified on all eleven).
- Every guarded script is bash-3.2-clean — no associative arrays, case
  conversion, or mapfile — so compat level 50 costs them nothing.

test/heredoc-pipe-deadlock.test.ts scans every tracked shell script for a
heredoc body in the 512B-64KiB window and fails without the guard, and
proves the mechanism at runtime on bash 5.2+ by asserting the body moves
from PIPE to TEMPFILE. On older bash the runtime half is skipped, since
the pipe path does not exist there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Absorbed from PR #2640 with authorship preserved. Wave adaptations: the pipe-probe test skips on minimal-/dev environments without /dev/stdin (it would report OTHER for an unobservable fd), and one caveat verified during review: on bash 4.3/4.4 (e.g. Git Bash), assigning BASH_COMPAT=50 prints a non-fatal 'invalid value' warning to stderr — those bashes are already on tempfiles, so the guard is a no-op there; windows-setup-e2e exercises this empirically.
2026-08-22 02:14:04 +00:00

103 lines
4.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# Migration: v1.37.0.0 — split-engine gbrain (remote MCP brain + optional
# local PGLite for code search per worktree).
#
# Per plan D5: prints a ONE-TIME discoverability notice for existing
# Path 4 users who don't yet have a local engine. They learn that
# symbol-aware code search (gbrain code-def / code-refs / code-callers)
# is now available via /setup-gbrain Step 4.5 if they want it.
#
# When to print the notice (state match — all conditions must hold):
# - ~/.claude.json declares mcpServers.gbrain.{type|transport} = http|sse|url
# OR mcpServers.gbrain.url is set (remote-http MCP active)
# - ~/.gbrain/config.json is absent (no local engine yet)
# - User has not previously opted out via:
# ~/.claude/skills/gstack/bin/gstack-config set local_code_index_offered true
#
# When silent: anything else (Path 1/2/3 users, anyone already on PGLite,
# anyone who opted out, anyone without remote-http MCP).
#
# Idempotency: writes a touchfile at ~/.gstack/.migrations/v1.37.0.0.done
# on completion. Re-running this script is silent if the touchfile exists,
# OR if local_code_index_offered=true.
# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
# pipe in the forked child before exec, with no reader on the other end. On
# macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any
# body >=512B blocks write() forever and the script hangs at startup with no
# output. Compat level 50 restores the tempfile path. These scripts are
# bash-3.2-clean, so the compat level costs them nothing. Not exported: the
# guard is per-script, and it survives `bash script.sh` call sites that
# bypass the shebang.
BASH_COMPAT=50
set -euo pipefail
if [ -z "${HOME:-}" ]; then
echo " [v1.37.0.0] HOME is unset — skipping migration." >&2
exit 0
fi
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
MIGRATIONS_DIR="$GSTACK_HOME/.migrations"
DONE_TOUCH="$MIGRATIONS_DIR/v1.37.0.0.done"
CONFIG_BIN="$HOME/.claude/skills/gstack/bin/gstack-config"
CLAUDE_JSON="$HOME/.claude.json"
GBRAIN_CONFIG="$HOME/.gbrain/config.json"
mkdir -p "$MIGRATIONS_DIR"
# Idempotency: already-ran skips silently.
if [ -f "$DONE_TOUCH" ]; then
exit 0
fi
# User opt-out skips silently AND records done.
if [ -x "$CONFIG_BIN" ]; then
if [ "$("$CONFIG_BIN" get local_code_index_offered 2>/dev/null)" = "true" ]; then
touch "$DONE_TOUCH"
exit 0
fi
fi
# State match: remote-http MCP active?
is_remote_http_mcp() {
[ -f "$CLAUDE_JSON" ] || return 1
command -v jq >/dev/null 2>&1 || return 1
local mtype murl
mtype=$(jq -r '.mcpServers.gbrain.type // .mcpServers.gbrain.transport // empty' "$CLAUDE_JSON" 2>/dev/null)
murl=$(jq -r '.mcpServers.gbrain.url // empty' "$CLAUDE_JSON" 2>/dev/null)
case "$mtype" in
url|http|sse) return 0 ;;
esac
[ -n "$murl" ] && return 0
return 1
}
# State match: local engine absent?
is_local_engine_missing() {
[ ! -f "$GBRAIN_CONFIG" ]
}
if is_remote_http_mcp && is_local_engine_missing; then
cat <<'NOTICE'
┌──────────────────────────────────────────────────────────────────┐
│ gstack v1.37.0.0 — split-engine gbrain │
│ │
│ Symbol-aware code search is now available on this machine. │
│ Your remote brain at gbrain MCP keeps working as today; you can │
│ add a tiny local PGLite (~30s, no accounts) for `gbrain │
│ code-def` / `code-refs` / `code-callers` queries per worktree. │
│ │
│ Run /setup-gbrain to opt in at Step 4.5. Or skip this notice │
│ permanently: │
│ gstack-config set local_code_index_offered true │
└──────────────────────────────────────────────────────────────────┘
NOTICE
fi
# Always touch done so we don't print again, regardless of state-match outcome.
touch "$DONE_TOUCH"