From 1481bdbb28517a6a373e75d6348f8875644fccaf Mon Sep 17 00:00:00 2001 From: Nicolas Payette Date: Wed, 2 Sep 2026 04:31:40 -0400 Subject: [PATCH] fix(backends): make a missing adapter a guardable refusal under set -e fm_backend_source attempted . backends/.sh with only an || return 1 guard, but on bash 3.2 a failed source of a nonexistent file is a fatal special-builtin error under set -e even inside ||/if guards: the whole caller shell exited with status 0 and its EXIT trap, so fm-teardown.sh's herdr preflight never reached its 'prerequisites are unavailable; nothing was changed' refusal and teardown silently reported success while doing nothing. This is the reproducible herdr-preflight-missing-adapter fixture failure in tests/fm-teardown.test.sh, failing since the fixture landed in 66b0f77 on stock macOS bash. Precheck adapter existence in fm_backend_source - the single owner of adapter sourcing - so a missing adapter returns 1 guardably for every backend and every caller, letting each consumer's own refusal fire. Every other failure class keeps its existing fail-stop behavior; no errexit suppression is introduced anywhere. Add a library-boundary regression in tests/fm-backend.test.sh: under set -eu, sourcing with the adapter absent must refuse instead of killing the caller. It fails before the fix (caller shell died, rc=1) and passes after; the existing teardown fixture stays the end-to-end regression. Validated on macOS stock bash 3.2.57 from a fresh upstream/main base: tests/fm-teardown.test.sh 58 ok (all four preflight refusal modes), tests/fm-backend.test.sh (new regression red on base, green here), tests/fm-backend-herdr.test.sh 182 ok, tests/fm-afk-launch.test.sh 46 ok, tmux/cmux smoke rc 0, bin/fm-lint.sh and bin/fm-doc-audience-check.sh clean, /bin/bash -n parse coverage over bin/*.sh and bin/backends/*.sh. --- bin/fm-backend.sh | 7 +++++++ tests/fm-backend.test.sh | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 2882f4a6af2..60516489a55 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -595,6 +595,13 @@ fm_backend_expected_label_of_selector() { # fm_backend_source() { # local name=$1 fm_backend_validate "$name" || return 1 + # A missing adapter file must be a guardable refusal, never a fatal source + # error: under `set -e` (fm-teardown.sh and other lifecycle consumers) a + # failed `.` on a nonexistent file exits the shell even inside `||`/`if` + # guards on bash 3.2, which silently reports success instead of reaching the + # caller's "prerequisites unavailable" refusal. Precheck existence so the + # source only ever runs on a present file. + [ -f "$FM_BACKEND_LIB_DIR/backends/$name.sh" ] || return 1 case "$name" in tmux) if [ -z "${_FM_BACKEND_TMUX_SOURCED:-}" ]; then diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index ece981b1222..294f7792e5c 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -503,6 +503,29 @@ test_backend_source_shell_portable() { pass "bash: fm_backend_source recognizes known backends and rejects unknown ones" } +test_backend_source_missing_adapter_refuses_guardably() { + local out rc tmpbin + # The teardown preflight relies on fm_backend_source returning 1 when an + # adapter file is absent. Under `set -e`, a failed `.` on a nonexistent file + # is a fatal special-builtin error on bash 3.2, even inside `||`/`if` guards, + # so the refusal must come from an existence precheck instead of the source + # itself failing - otherwise lifecycle callers silently exit as if they + # succeeded. This mirrors the missing-adapter teardown fixture at the + # library boundary for every backend and caller. + tmpbin="$TMP_ROOT/missing-adapter-lib/backends" + mkdir -p "$tmpbin" + rc=0 + out=$(set -eu; cd "$ROOT" \ + && . bin/fm-backend.sh \ + && FM_BACKEND_LIB_DIR="$tmpbin" \ + && if fm_backend_source herdr; then echo sourced; else echo refused; fi) || rc=$? + [ "$rc" -eq 0 ] \ + || fail "fm_backend_source missing adapter: the caller shell died (rc=$rc) instead of refusing" + [ "$out" = "refused" ] \ + || fail "fm_backend_source missing adapter: expected 'refused', got: $out" + pass "fm_backend_source refuses a missing adapter guardably under set -e instead of exiting the caller" +} + test_backend_validate_spawn_accepts_orca() { local out fm_backend_validate_spawn tmux 2>/dev/null || fail "fm_backend_validate_spawn should accept tmux" @@ -1126,6 +1149,7 @@ test_backend_name_autodetect_notice test_backend_name_explicit_beats_detection test_backend_validate_refuses_unknown test_backend_source_shell_portable +test_backend_source_missing_adapter_refuses_guardably test_backend_validate_spawn_accepts_orca test_meta_get_and_backend_of_meta test_resolve_selector_three_forms