Files
gstack/test/heredoc-pipe-deadlock.test.ts
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

123 lines
4.9 KiB
TypeScript

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 <<EOF
$body
EOF
`;
const run = (guard: string) =>
(spawnSync('bash', ['-c', probe(guard)], { encoding: 'utf-8' }).stdout ?? '').trim();
expect(run('')).toBe('PIPE');
expect(run('BASH_COMPAT=50')).toBe('TEMPFILE');
});
});