From 2b96678f8a6ab85f17e567a9f2a8472cd2f4f9f2 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 14 Aug 2026 15:53:47 -0700 Subject: [PATCH] fix(update-check): crash emits CHECK_FAILED instead of reading as up-to-date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gstack-update-check signals "up to date" with SILENCE, and it runs under set -e — so any unguarded mid-script failure exited quietly and was indistinguishable from a current install. Observed live as a 45-release silent-staleness incident. An ERR trap (with -E so it propagates into functions) now emits a CHECK_FAILED sentinel naming the line and status, and exits 0 so caller `|| true` guards can't eat it. Behavioral tests cover both the crash and the healthy-silent paths; egress-receipt wiring is untouched and still pinned by test/egress-receipt-wiring.test.ts. Fixes #1974. (#2378's HEAD-SHA staleness half was already fixed on main by the ls-remote + SHA-pinned VERSION resolution — close as already-fixed.) Co-Authored-By: Claude Fable 5 --- bin/gstack-update-check | 8 ++++ test/update-check-crash-sentinel.test.ts | 51 ++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 test/update-check-crash-sentinel.test.ts diff --git a/bin/gstack-update-check b/bin/gstack-update-check index 2d6d8af44..3573c7878 100755 --- a/bin/gstack-update-check +++ b/bin/gstack-update-check @@ -13,6 +13,14 @@ # GSTACK_STATE_DIR — override ~/.gstack state directory set -euo pipefail +# A crash must not read as "up to date" (#1974). With set -e, any unguarded +# failure used to exit silently — and silence IS the up-to-date signal, so a +# broken check was indistinguishable from a current install (observed live as +# a 45-release silent-staleness incident). -E propagates the trap into +# functions/subshells; exit 0 keeps callers' `|| true` from eating the line. +set -E +trap 'rc=$?; echo "CHECK_FAILED gstack-update-check crashed (line $LINENO, rc=$rc) — update status UNKNOWN, not up-to-date"; exit 0' ERR + GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" diff --git a/test/update-check-crash-sentinel.test.ts b/test/update-check-crash-sentinel.test.ts new file mode 100644 index 000000000..73177423a --- /dev/null +++ b/test/update-check-crash-sentinel.test.ts @@ -0,0 +1,51 @@ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +// #1974: gstack-update-check runs under set -e, and its "up to date" signal +// is SILENCE — so any unguarded crash used to exit quietly and read as +// up-to-date (a real 45-release silent-staleness incident). The ERR trap must +// convert a crash into a visible CHECK_FAILED line, and the healthy path must +// stay silent. + +const ROOT = path.resolve(import.meta.dir, '..'); +const SCRIPT = path.join(ROOT, 'bin', 'gstack-update-check'); + +function run(env: Record) { + return spawnSync('bash', [SCRIPT], { + encoding: 'utf8', + env: { ...process.env, ...env }, + timeout: 15000, + }); +} + +describe('gstack-update-check crash sentinel (#1974)', () => { + test('a mid-script crash emits CHECK_FAILED instead of silent up-to-date', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-upd-')); + try { + // STATE_DIR pointing at a FILE makes the state mkdir fail — an + // unguarded failure representative of any mid-script crash. + const asFile = path.join(dir, 'statefile'); + fs.writeFileSync(asFile, ''); + const r = run({ GSTACK_STATE_DIR: asFile, GSTACK_REMOTE_URL: 'file:///dev/null' }); + expect(r.status).toBe(0); + expect(r.stdout).toContain('CHECK_FAILED'); + expect(r.stdout).toContain('UNKNOWN'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('healthy up-to-date path stays silent (no sentinel noise)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-upd-')); + try { + const r = run({ GSTACK_STATE_DIR: path.join(dir, 'state'), GSTACK_REMOTE_URL: 'file:///dev/null' }); + expect(r.status).toBe(0); + expect(r.stdout).not.toContain('CHECK_FAILED'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +});