From 980374c495730e7c4eb6fe87e2f4b452d0a2033d Mon Sep 17 00:00:00 2001 From: Nik Divjak Date: Wed, 27 May 2026 13:11:06 +0200 Subject: [PATCH] chore(scripts): install-local.sh smoke-test before binary swap (#258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a smoke-test step that runs BEFORE the freshly-built binary replaces the brew Cellar binary. Catches the class of regression where the build succeeds at the bundler layer but the produced binary crashes at module-load / plugin-resolution / config-read — the surface that silently shipped a U.length crash 2026-05-27 (PR #14-17 era) and went undetected for ~1h because already-running tabs kept their mmap'd old binary. Two layers, both cheap (~0.7s total on a healthy binary): 1. `--version` — catches module-load + top-level import crashes 2. `debug info` — catches plugin-resolution + config-load regressions (exercises the same module graph the TUI mount path does) If either layer fails — non-zero exit, OR exit 0 with TypeError / ReferenceError / SyntaxError / fatal-error / U.length / 'Cannot read prop' on stderr — the install aborts and the working brew binary stays put. The build output is preserved at $SRC_BIN for inspection. `--skip-smoke` flag provided for emergency overrides (rebuild loops on a known-broken state where the new binary is wanted despite a smoke fail). `timeout` wrapper detection: macOS doesn't ship coreutils by default, so `timeout`/`gtimeout` are detected at runtime; absent both, the smoke runs unwrapped (the commands themselves exit fast, so the wrapper is belt-and-suspenders, not load-bearing). Verified locally with three integration cases against the in-script smoke block: - Healthy gruntcode binary → proceeds to install - Crashing fake-bin → refused + exit 1, install aborted - Stealth crash (exit 0 + TypeError on stderr) → refused + exit 1 - `--skip-smoke` → smoke skipped, install proceeds Help text + doc-comment header updated; new `Smoke-test (pre-install verification)` section explains what the smoke does + how to disable. Closes #258. --- scripts/install-local.sh | 95 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 2 deletions(-) diff --git a/scripts/install-local.sh b/scripts/install-local.sh index 71948eb16214..60c45fc12a35 100755 --- a/scripts/install-local.sh +++ b/scripts/install-local.sh @@ -4,7 +4,7 @@ # without waiting for CI to publish a tagged release. # # Usage: -# bash scripts/install-local.sh [version-label] +# bash scripts/install-local.sh [version-label] [--ahead-ok] [--skip-smoke] # # Version label # ------------- @@ -32,6 +32,19 @@ # "you're building something CI hasn't released, the DB is now ahead of brew" # trap. Pass `--ahead-ok` to suppress the warning when intentional. # +# Smoke-test (pre-install verification) +# ------------------------------------- +# Before stomping the Cellar binary we exercise the freshly-built binary with +# two cheap commands: +# 1. `--version` — must exit 0 with a parseable version string +# 2. `debug info` — must exit 0 with a clean stderr (no TypeError / +# ReferenceError / SyntaxError / fatal-error / U.length) +# If either fails, the install aborts and the working brew binary stays put. +# Catches the class of regression where the build succeeds at the bundler +# layer but the produced binary crashes at module-load / plugin-resolution / +# config-read — surfaces caught 2026-05-27 (PR #14-17 era) that previously +# would have gone live silently. Pass `--skip-smoke` to override. +# # Pairs with the standard release flow: tag a release for distribution, but # install locally immediately so Nik isn't blocked on CI for his own bin. @@ -41,12 +54,14 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # --- parse args -------------------------------------------------------------- AHEAD_OK=0 +SKIP_SMOKE=0 EXPLICIT_LABEL="" for arg in "$@"; do case "$arg" in --ahead-ok) AHEAD_OK=1 ;; + --skip-smoke) SKIP_SMOKE=1 ;; -h|--help) - sed -n '2,40p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + sed -n '2,50p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' exit 0 ;; *) EXPLICIT_LABEL="$arg" ;; @@ -128,6 +143,82 @@ if [ -z "$SRC_BIN" ] || [ ! -x "$SRC_BIN" ]; then exit 1 fi +# --- smoke-test -------------------------------------------------------------- +# Verify the new binary actually loads + executes a short command path before +# we stomp the working brew Cellar binary. Catches the class of regression +# where a JS-level crash (e.g. U.length on undefined) is silently produced by +# the build but only surfaces when a NEW tab tries to mount the TUI — by which +# time the old binary is already gone and Nik has no working gruntcode. +# +# Two layers, both cheap: +# 1. --version catches module-load + top-level import crashes +# 2. debug info catches plugin-resolution + config-load regressions +# (touches the same module graph the TUI mount does) +# +# Pass --skip-smoke for emergencies (rebuild loop on a known-broken state +# where you intentionally want the new binary installed despite a smoke fail). +if [ "$SKIP_SMOKE" -eq 1 ]; then + echo "→ Smoke-test SKIPPED (--skip-smoke)" +else + echo "→ Smoke-testing new binary at $SRC_BIN..." + SMOKE_LOG=$(mktemp -t gruntcode-smoke.XXXXXX) + SMOKE_FAILED=0 + + # `timeout` is a GNU coreutils binary; on macOS it's `gtimeout` (via `brew + # install coreutils`) or not present at all. Both --version and `debug info` + # exit in <1s on a healthy binary, so a hang here would indicate a real + # regression worth catching — but we only insist on the wrapper if it's + # available. On macOS without coreutils we run unwrapped + rely on the + # commands being fast. + if command -v timeout >/dev/null 2>&1; then + TIMEOUT_BIN="timeout 10" + elif command -v gtimeout >/dev/null 2>&1; then + TIMEOUT_BIN="gtimeout 10" + else + TIMEOUT_BIN="" + fi + + # Layer 1: --version. Exits 0 immediately if module-load succeeds. + if ! $TIMEOUT_BIN "$SRC_BIN" --version >"$SMOKE_LOG" 2>&1; then + SMOKE_FAILED=1 + echo " ✗ \`--version\` failed" >&2 + else + SMOKE_VERSION=$(cat "$SMOKE_LOG") + echo " ✓ --version → $SMOKE_VERSION" + fi + + # Layer 2: debug info. Exercises plugin-resolution + config load. + if [ "$SMOKE_FAILED" -eq 0 ]; then + if ! $TIMEOUT_BIN "$SRC_BIN" debug info >"$SMOKE_LOG" 2>&1; then + SMOKE_FAILED=1 + echo " ✗ \`debug info\` failed" >&2 + elif grep -qE '(TypeError|ReferenceError|SyntaxError|fatal error|Cannot read prop|U\.length)' "$SMOKE_LOG"; then + # Catch JS-level crash markers that may print on otherwise-zero-exit + # paths — e.g. an unhandled rejection in a plugin load can still produce + # exit 0 but with `TypeError` on stderr. + SMOKE_FAILED=1 + echo " ✗ \`debug info\` exited 0 but stderr looks crashy:" >&2 + sed 's/^/ /' "$SMOKE_LOG" >&2 + else + echo " ✓ debug info clean" + fi + fi + + if [ "$SMOKE_FAILED" -ne 0 ]; then + echo >&2 + echo "✗ Smoke-test FAILED. Refusing to replace the Cellar binary." >&2 + echo " Build output preserved at: $SRC_BIN" >&2 + echo " Last smoke log:" >&2 + sed 's/^/ /' "$SMOKE_LOG" >&2 + echo >&2 + echo " Rebuild after fixing the regression, or pass --skip-smoke to override." >&2 + exit 1 + fi + + rm -f "$SMOKE_LOG" + echo " Smoke OK — proceeding to install." +fi + # --- install ----------------------------------------------------------------- CELLAR_BIN=$(find /opt/homebrew/Cellar/gruntcode -name gruntcode -type f 2>/dev/null | sort -r | head -1) if [ -z "$CELLAR_BIN" ]; then