fix: fail closed on missing teardown dependencies - #2646
Open
tiago-peixoto wants to merge 5 commits into
Open
Conversation
Stock macOS Bash 3.2 - what /usr/bin/env bash resolves to on the machines
this fleet runs on - terminates the whole shell when `.` cannot find its
file, and does so with status 0. It ignores both the `|| return 1` written
beside the source and the `if !` the caller wrapped the call in.
So a missing backend adapter ended fm-teardown.sh mid-run while reporting
success: the caller records the task as cleaned up when nothing was cleaned
up, and the safety refusal teardown was about to print never appears. Bash 5
returns 1 as written, which is why the Linux CI lanes never saw it and
tests/fm-teardown.test.sh's herdr preflight case failed only locally.
Test the adapter file before sourcing it, through one helper the five
backends share. The guard is read and written with eval because this file is
also sourced from zsh, where ${!var} and printf -v do not mean what they mean
in bash.
Verified against GNU bash 3.2.57(1)-release (arm64-apple-darwin25); the new
case in tests/fm-backend.test.sh fails without this change.
Sourcing a missing sibling library can terminate a script silently and successfully on the Bash that macOS ships by default, exposing every stock-Mac Firstmate user wherever a script sources a sibling. On GNU bash 3.2.57, the `.` builtin terminates the whole script with status 0 when it cannot find a file. It ignores both an adjacent `|| return 1` and an enclosing `if !`. Bash 5 returns 1 as written, which is why the Linux CI lanes never exposed the defect. The first call site was fm_backend_source loading a backend adapter. A missing adapter ended fm-teardown.sh mid-run while reporting success, so the caller recorded the task as cleaned up when nothing was cleaned up and the safety refusal never printed. The five adapters now share one refusable once-only loader, with its guard read and written through eval because fm-backend.sh is also sourced from zsh. The second call site was found by tracing an unexplained CI failure to its exit point, where teardown returned status 0 with the task temp directory still present. bin/backends/tmux.sh sourced fm-session-lock-lib.sh and fm-cursor-lib.sh at top level, while the test fake root provided neither. The first source-site enumeration was wrong because it matched a syntax pattern: `$SCRIPT_DIR`, `$FM_BACKEND_LIB_DIR`, and `$(dirname ...)` path forms. Sites using differently named directory variables were invisible, including `$_FM_PF_LIB_DIR` and the Herdr, Zellij, and cmux backend-root variables, so that enumeration reported zero sites in all four owners. The pattern could only find what it already described and therefore could not reveal that its premise was too narrow. Enumeration is now by input. It starts from every command whose sourcing act is `.` or `source`, extracts the final shell-script target without depending on its path expression, and closes transitively from fm-teardown.sh plus all five backend adapters that recorded task metadata can dispatch. The reachable teardown chain contains 25 files and 29 sibling-source sites. All 29 now fail closed: 14 in fm-teardown.sh, three each in the tmux and Herdr adapters, two each in the Zellij and cmux adapters, one in the Orca adapter, two in fm-tmux-lib.sh, one in fm-public-followup-lib.sh, and one in fm-session-lock-lib.sh. The repository contains 251 sibling-source sites across 85 files when measured by the sourcing act. This change closes the reachable teardown chain that owns destructive cleanup. The remaining repository sites share the hazard and are deliberately not touched here. Zellij and cmux load their siblings after the worktree has been returned but before the task temp directory and durable state are removed. A silent death there leaves state behind after destructive work has already happened. That is worse than a teardown that fails before doing anything because the half-completed success tells the caller cleanup is done. Each inline guard tests that its target is readable and refuses by name. Guards stay above shellcheck source directives so those directives remain adjacent to their source commands. tests/fm-gotmp.test.sh now supplies the real tmux adapter dependencies, keeps the top-level loud-refusal proof, and adds RED-first coverage for a missing Zellij sibling on the destructive path. Before the guard, stock macOS Bash returned status 0 without the named refusal. On hosts that sign commits by default, tests/fm-backend.test.sh and tests/fm-teardown.test.sh have pre-existing fixture failures against base b57c4d6 because tests/lib.sh does not neutralize commit.gpgsign. This change does not alter those unrelated fixtures.
…ive teardown A guard whose caller discards its verdict is not a guard. The previous commit made a missing sibling library fail instead of ending the script silently with status 0. It did not check what the callers then did with that failure. fm-teardown.sh calls fm_backend_kill as `2>/dev/null || true`, after the worktree has been returned and before the task temp directory and durable state are removed, so on every non-Herdr, non-Orca backend the new failure was swallowed: the worktree was returned, the endpoint was left live, the recovery state was deleted, and teardown reported success. The forced descendant path had the same shape, wrapping the guard's exit inside an ignored subshell and deleting child state anyway. The observable outcome on those paths was therefore unchanged by the guards - the defect was relocated, not closed. Teardown now preflights the backend code for its own task and for every descendant it will clean up, before its first destructive step rather than merely before the kill, and refuses loudly with nothing returned, killed or deleted when that code cannot be loaded. Endpoint killing itself stays best-effort: a pane that has already gone must not become a refusal, and a test pins that boundary so this cannot over-correct into failing ordinary teardowns. How this was missed is worth recording. The stderr suppression at that call site had already been seen while retargeting a test, and the conclusion drawn was "the message is hidden" - without asking what happened to the failure. That is the same falsifier weakness as the earlier enumeration mistake in this branch, in a third disguise: checking what is VISIBLE rather than what SURVIVES. A reader who knows that is why these guards are now wired end-to-end can trust them; a reader who sees only the guards cannot.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Close one defect class upstream: sourcing a missing sibling library can end a script silently and successfully on the Bash that macOS ships by default, so any Firstmate user on a stock Mac is exposed wherever a script sources a sibling.
THE HAZARD, which is the point of this change. On GNU bash 3.2.57 - what /usr/bin/env bash resolves to on stock macOS - the
.builtin on a file it cannot find terminates the whole script and does so with status 0. It ignores both a|| return 1written beside the source and an enclosingif !. Bash 5 returns 1 as written, which is why this repository's Linux CI lanes have never seen it. The consequence is a script that dies partway through while reporting success to its caller. That is worse than a crash: a caller records the work as done.TWO CALL SITES, each with its own failing-test evidence.
These are the SAME defect at two call sites, which is why they ship as one change rather than two isolated line fixes. Diagnosis order matters for review: the second was found by tracing an unexplained CI failure to its exit point, where teardown returned status 0 with the tasktmp still present.
WHAT THIS CLOSES, stated precisely rather than generously. Every sibling source on the teardown chain now fails closed: 20 sites across fm-teardown.sh (14), bin/backends/tmux.sh (3), bin/fm-tmux-lib.sh (2) and bin/backends/orca.sh (1), each testing the file is readable and refusing by name if it is not. The repository has 216 such sibling-source sites across 69 files; this change closes the chain that demonstrably fails and that owns destructive cleanup, and the remaining sites share the hazard and are deliberately not touched here. The value to a reviewer is knowing which class is closed and where the boundary is, not a claim that all sourcing is now safe.
IMPLEMENTATION NOTES A REVIEWER WOULD NOT GUESS. The adapter guard reads and writes its once-only flag through eval rather than bash's ${!var} indirection or printf -v, because bin/fm-backend.sh is also sourced from zsh, where those constructs mean something else or nothing at all; tests/fm-backend.test.sh already exercises the zsh path and fails otherwise. Each inline guard is placed ABOVE the
# shellcheck source=directive rather than between it and the source, because that directive must stay adjacent to the source line or shellcheck stops following the file.TESTS. tests/fm-gotmp.test.sh gains the two missing symlinks so its fake root matches reality, and a new case asserting that a missing library produces a named non-zero refusal rather than a silent success. That case is not vacuous: with the guards removed the script dies with bash's own "No such file or directory" and never names what was missing, so the case fails. The case removes a library fm-teardown.sh sources directly at top level, because a library reached through a best-effort call site refuses just as loudly but that site discards stderr, which would hide the diagnostic being asserted.
PRE-EXISTING FAILURES, not introduced here. On a host whose git signs commits by default, tests/fm-backend.test.sh and tests/fm-teardown.test.sh already fail against this branch's exact base (b57c4d6) - verified on a pristine checkout of it - because tests/lib.sh does not neutralise commit.gpgsign for fixture commits. Those failures are unrelated to this change and are not addressed here.
SCOPE. This is one general fix carved out of a larger local change at the captain's direction, so each separable thing is judged as what it is. It carries nothing project-specific.
CHAIN CLOSURE, after review. The first version of this change guarded only the sites a syntax-pattern enumeration could see, and claimed the teardown chain was closed when it was not. That claim is corrected: enumeration is now by the sourcing act with shape-independent target extraction, transitively closed from fm-teardown.sh and all five backend adapters, because teardown dispatches whichever backend the task recorded. The reachable chain is 25 files and 29 sibling-source sites; all 29 now fail closed. The repository has 251 such sites across 85 files measured the same way, and the ones outside the reachable teardown chain are deliberately untouched and stated as such.
RECOVERY NOTE, for accuracy about this branch's history. A previous run of this pipeline applied the chain-closure work correctly but committed it as a sibling of the submitted head rather than a descendant, and the pipeline refused to proceed rather than lose the reviewed change - the protection worked as designed. The prior head is preserved locally as tag fm-bash32-preabort-03a7738; the current head strictly contains it, verified as 25 insertions and zero deletions, so nothing from it was dropped. No history was rewritten out-of-band by hand.
What Changed
Risk Assessment
✅ Low: The change is fail-closed, covers the complete reachable teardown sourcing chain, and preflights parent and descendant backends before destructive cleanup without altering best-effort endpoint-kill behavior.
Testing
On stock macOS Bash 3.2.57, targeted backend, temp-cleanup, and teardown validation now succeeds with commit signing neutralized for fixtures; an initial zsh regression was reproduced, fixed test-first, and reverified, while two CLI transcripts directly demonstrate non-zero named refusals with cleanup state preserved and a final audit confirms all 29 reachable source sites are guarded.
Evidence: Stock macOS Bash missing-adapter refusal
GNU bash 3.2.57 exit_status=1 REFUSED: backend 'tmux' required by task evidence-task cannot be loaded tasktmp_preserved=yes metadata_preserved=yesEvidence: Stock macOS Bash missing-sibling refusal
GNU bash 3.2.57 exit_status=1 missing required library: <fixture>/bin/fm-nm-run-lib.sh tasktmp_preserved=yes metadata_preserved=yesPipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
✅ **Review** - passed
✅ No issues found.
✅ **Test** - passed
✅ No issues found.
Confirmed target87cf89d36fbaf206005763ebc185f20bd949429cand GNU Bash 3.2.57.Initial targeted run:env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false bin/fm-test-run.sh tests/fm-backend.test.sh tests/fm-gotmp.test.sh tests/fm-teardown.test.sh; exposed the zsh regression while both cleanup suites completed successfully.RED/GREEN verification:env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false bin/fm-test-run.sh tests/fm-backend.test.sh; failed before the correction and exited 0 afterward, exercising all five adapters through zsh plus Bash 3.2 missing-adapter handling.Final cleanup verification:env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false bin/fm-test-run.sh tests/fm-gotmp.test.sh tests/fm-teardown.test.sh; exited 0 against the final files.Manual/bin/bashteardown with a missing tmux adapter returned 1, named the backend, and preserved task temp data and metadata.Manual/bin/bashteardown with a missing sibling library returned 1, named the missing file, and preserved task temp data and metadata.Audited the reachable sourcing acts: 29 sites across the nine sourcing owners, with 0 invalid guard/directive/source triplets.✅ **Document** - passed
✅ No issues found.
🔧 **Lint** - 1 issue found → auto-fixed ✅
🔧 Fix: Fix backend preflight ShellCheck scope warnings
✅ Re-checked - no issues remain.
✅ **Push** - passed
✅ No issues found.