diff --git a/bin/gstack-artifacts-init b/bin/gstack-artifacts-init index 9691c226e..6ad2dcfb6 100755 --- a/bin/gstack-artifacts-init +++ b/bin/gstack-artifacts-init @@ -34,6 +34,16 @@ # GSTACK_HOME — override ~/.gstack # USER — fallback for repo naming if $USER is unset +# 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 GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" diff --git a/bin/gstack-brain-restore b/bin/gstack-brain-restore index bab38f55f..781ba700a 100755 --- a/bin/gstack-brain-restore +++ b/bin/gstack-brain-restore @@ -25,6 +25,16 @@ # Env: # GSTACK_HOME — override ~/.gstack +# 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 GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" diff --git a/bin/gstack-brain-sync b/bin/gstack-brain-sync index 0462c1ce6..fcdd48b94 100755 --- a/bin/gstack-brain-sync +++ b/bin/gstack-brain-sync @@ -17,6 +17,16 @@ # Env: # GSTACK_HOME — override ~/.gstack (aligns with writers). +# 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 -uo pipefail GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" diff --git a/bin/gstack-distill-free-text b/bin/gstack-distill-free-text index fe75c45a4..a7e997c0a 100755 --- a/bin/gstack-distill-free-text +++ b/bin/gstack-distill-free-text @@ -20,6 +20,16 @@ # auditability via --status when you want it. # Per D6: Anthropic SDK direct call, fail-loud on missing ANTHROPIC_API_KEY. set -euo pipefail + +# 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 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" diff --git a/bin/gstack-jsonl-merge b/bin/gstack-jsonl-merge index d2fa5744c..9c4e22688 100755 --- a/bin/gstack-jsonl-merge +++ b/bin/gstack-jsonl-merge @@ -24,6 +24,16 @@ # 0 — merge succeeded, result written to # 1 — error; git treats as conflict and stops the merge +# 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 -uo pipefail if [ "$#" -lt 3 ]; then diff --git a/bin/gstack-settings-hook b/bin/gstack-settings-hook index a658048fa..cfb9feef6 100755 --- a/bin/gstack-settings-hook +++ b/bin/gstack-settings-hook @@ -54,6 +54,16 @@ # on disk. `rollback` is a single-step undo of the last real mutation. # - writes are atomic: unique tmp file + rename (a fixed tmp name would let # two concurrent writers rename a half-written file into place). +# 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 ACTION="${1:-}" diff --git a/bin/gstack-team-init b/bin/gstack-team-init index 99538425f..4f41e66ca 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -7,6 +7,16 @@ # # Run from the root of your team's repo (not from the gstack directory). +# 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 MODE="${1:-}" diff --git a/gstack-upgrade/migrations/v1.27.0.0.sh b/gstack-upgrade/migrations/v1.27.0.0.sh index 021c6a566..65ac82890 100755 --- a/gstack-upgrade/migrations/v1.27.0.0.sh +++ b/gstack-upgrade/migrations/v1.27.0.0.sh @@ -24,6 +24,16 @@ # the brain admin to run on the brain host # # All steps are idempotent. Re-running after partial completion is safe. +# 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 diff --git a/gstack-upgrade/migrations/v1.37.0.0.sh b/gstack-upgrade/migrations/v1.37.0.0.sh index b173f5844..b60b8530c 100755 --- a/gstack-upgrade/migrations/v1.37.0.0.sh +++ b/gstack-upgrade/migrations/v1.37.0.0.sh @@ -21,6 +21,16 @@ # 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 diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 8869212ab..915011cfc 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -13,6 +13,16 @@ # ./scripts/build-app.sh # Build .app + DMG # ./scripts/build-app.sh --no-dmg # Build .app only +# 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 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" diff --git a/setup b/setup index 83b468bdd..21ec6c0b0 100755 --- a/setup +++ b/setup @@ -3,6 +3,16 @@ set -e umask 077 # Restrict new files to owner-only (0o600 files, 0o700 dirs) +# 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 — ./setup --help would hang with no +# output. Compat level 50 restores the tempfile path. This script is +# bash-3.2-clean, so the compat level costs it nothing. Not exported: the +# guard is per-script, and it survives `bash setup` call sites that bypass +# the shebang. +BASH_COMPAT=50 + usage() { cat <<'EOF' gstack setup — install gstack skills + build browse binary diff --git a/test/heredoc-pipe-deadlock.test.ts b/test/heredoc-pipe-deadlock.test.ts new file mode 100644 index 000000000..0518fc366 --- /dev/null +++ b/test/heredoc-pipe-deadlock.test.ts @@ -0,0 +1,122 @@ +import { describe, test, expect } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { execSync, spawnSync } from 'child_process'; + +/** + * bash 5.2+ delivers a heredoc body of 64KiB or less through a pipe: the + * forked child writes the whole body before exec, and nothing reads the other + * end until the command starts. On macOS under pipe-KVA pressure the kernel + * hands a fresh pipe a 512-byte buffer, so any body of 512 bytes or more + * blocks write() forever — the script hangs at startup, silently, with no + * output and no error. The runtime capacity check bash would need + * (F_GETPIPE_SZ) is Linux-only. + * + * Compat level 50 restores the pre-5.2 tempfile path. Every script that ships + * an in-window heredoc must set it, and this scanner fails the suite when a + * new one appears without the guard. + * + * The guard is deliberately 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. + */ + +const ROOT = path.resolve(import.meta.dir, '..'); + +// Inclusive byte window where the pipe path is taken AND a starved pipe can +// block. Bodies over 64KiB fall back to a tempfile on their own. +const MIN_BODY = 512; +const MAX_BODY = 64 * 1024; + +const GUARD_RE = /^\s*(?::\s*"\$\{)?BASH_COMPAT(?:[:=]|\}")/m; + +function trackedShellScripts(): string[] { + const out = execSync('git ls-files', { cwd: ROOT, encoding: 'utf-8', maxBuffer: 32 * 1024 * 1024 }); + return out + .split('\n') + .map((s) => s.trim()) + .filter(Boolean) + .filter((f) => { + const abs = path.join(ROOT, f); + if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return false; + if (f.endsWith('.sh')) return true; + const head = fs.readFileSync(abs).subarray(0, 64).toString('utf-8'); + return /^#!.*\b(bash|sh)\b/.test(head); + }); +} + +/** Heredocs in `content` whose body lands inside the deadlock window. */ +function inWindowHeredocs(content: string): { line: number; tag: string; bytes: number }[] { + const lines = content.split('\n'); + const hits: { line: number; tag: string; bytes: number }[] = []; + for (let i = 0; i < lines.length; i++) { + const m = /<<-?\s*'?([A-Za-z_][A-Za-z0-9_]*)'?/.exec(lines[i]); + if (!m) continue; + const tag = m[1]; + let j = i + 1; + const body: string[] = []; + while (j < lines.length && lines[j].trim() !== tag) body.push(lines[j++]); + const bytes = Buffer.byteLength(body.join('\n')) + 1; + if (bytes >= MIN_BODY && bytes <= MAX_BODY) hits.push({ line: i + 1, tag, bytes }); + i = j; + } + return hits; +} + +describe('heredoc pipe-deadlock guard', () => { + test('every script with an in-window heredoc sets BASH_COMPAT', () => { + const violations: string[] = []; + for (const rel of trackedShellScripts()) { + const content = fs.readFileSync(path.join(ROOT, rel), 'utf-8'); + const hits = inWindowHeredocs(content); + if (hits.length === 0) continue; + if (GUARD_RE.test(content)) continue; + for (const h of hits) violations.push(`${rel}:${h.line} <<${h.tag} body=${h.bytes}B`); + } + if (violations.length > 0) { + throw new Error( + `Heredoc bodies in the ${MIN_BODY}-${MAX_BODY}B pipe window without a BASH_COMPAT guard:\n ` + + violations.join('\n ') + + `\n\nFix: add \`BASH_COMPAT=50\` near the top of the script (below any ` + + `\`--help\` sed range that reads $0), or shrink the body under ${MIN_BODY}B, ` + + `or pipe it in with printf so a live reader exists.`, + ); + } + expect(violations).toEqual([]); + }); + + test('the guard actually moves the body off the pipe', () => { + const bash = spawnSync('bash', ['-c', 'echo "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}"'], { + encoding: 'utf-8', + }); + const version = (bash.stdout ?? '').trim(); + const [maj, min] = version.split('.').map((n) => parseInt(n, 10)); + // Only 5.2+ takes the pipe path at all; older bash is already on tempfiles. + if (!(maj > 5 || (maj === 5 && min >= 2))) { + expect(version).toBeTruthy(); + return; + } + + // Some sandboxes/containers ship a minimal /dev without /dev/stdin — the + // probe medium itself is absent there, so -p/-f both report false and the + // probe would answer OTHER for an unobservable fd. Skip rather than fail. + const devStdin = spawnSync('bash', ['-c', '[ -e /dev/stdin ] && echo yes || echo no'], { + encoding: 'utf-8', + }); + if ((devStdin.stdout ?? '').trim() !== 'yes') return; + + const probe = (guard: string) => `#!/usr/bin/env bash +${guard} +body=$(printf 'x%.0s' $(seq 1 1000)) +probe() { if [ -p /dev/stdin ]; then echo PIPE; elif [ -f /dev/stdin ]; then echo TEMPFILE; else echo OTHER; fi; } +probe < + (spawnSync('bash', ['-c', probe(guard)], { encoding: 'utf-8' }).stdout ?? '').trim(); + + expect(run('')).toBe('PIPE'); + expect(run('BASH_COMPAT=50')).toBe('TEMPFILE'); + }); +});