diff --git a/bin/fm-guard.sh b/bin/fm-guard.sh index 024491defc..15b844f307 100755 --- a/bin/fm-guard.sh +++ b/bin/fm-guard.sh @@ -48,7 +48,7 @@ fm_guard_stale_episode_key() { local state=$1 beat m beat="$state/.last-watcher-beat" if [ -e "$beat" ]; then - m=$(fm_sup_stat_mtime "$beat") + m=$(fm_stat_mtime "$beat") printf 'beat:%s\n' "${m:-unknown}" else printf 'beat:absent\n' diff --git a/bin/fm-stat-lib.sh b/bin/fm-stat-lib.sh new file mode 100644 index 0000000000..a6c901385c --- /dev/null +++ b/bin/fm-stat-lib.sh @@ -0,0 +1,50 @@ +# shellcheck shell=bash +# bin/fm-stat-lib.sh - the single owner of firstmate's portable file-mtime read. +# +# Usage: . bin/fm-stat-lib.sh ; fm_stat_mtime +# +# WHY THIS EXISTS (upstream issue #464): the supervision and guard path needs a +# file's modification time as a bare epoch second, but the two stat flavors +# disagree on the flag - GNU coreutils stat uses `-c %Y`, BSD/macOS stat uses +# `-f %m`. The historical helpers branched on `uname`, choosing the BSD `-f` +# form on Darwin. That misfires when GNU coreutils' stat is ahead of +# /usr/bin/stat on PATH (a common Homebrew layout): GNU stat reads `-f` as its +# filesystem-mode flag and prints a multi-line "File: ..." dump instead of an +# epoch, and under `set -u` that stray string crashed the watcher with +# "File: unbound variable" and took supervision down. +# +# THE FIX: detect the stat flavor by CAPABILITY, never by OS. Probe `stat -c %Y` +# once against a path guaranteed to be statable (`/`); if that yields a bare +# number this stat speaks GNU and we keep `-c %Y`, otherwise we use the BSD +# `-f %m` form. The probe is a single cheap stat. The FM_STAT_FLAVOR cache avoids +# re-probing only within the same shell scope, so a caller that invokes +# fm_stat_mtime through command substitution re-probes once per call, which is +# negligible. +# +# fm_stat_mtime prints the mtime as epoch seconds on success and nothing (with a +# non-zero exit) when the path cannot be stat'd, matching the old per-helper form +# so callers keep their existing empty-string checks. + +FM_STAT_FLAVOR=${FM_STAT_FLAVOR:-} + +# fm_stat_detect_flavor: probe the on-PATH stat once and cache gnu/bsd in +# FM_STAT_FLAVOR. GNU stat answers `-c %Y /` with a bare epoch; BSD stat rejects +# `-c` and prints nothing to stdout, so a non-numeric probe means BSD. +fm_stat_detect_flavor() { + local probe + probe=$(stat -c %Y / 2>/dev/null) + case "$probe" in + ''|*[!0-9]*) FM_STAT_FLAVOR=bsd ;; + *) FM_STAT_FLAVOR=gnu ;; + esac +} + +# fm_stat_mtime : echo the file's mtime as epoch seconds, nothing on error. +fm_stat_mtime() { + [ -n "$FM_STAT_FLAVOR" ] || fm_stat_detect_flavor + if [ "$FM_STAT_FLAVOR" = gnu ]; then + stat -c %Y "$1" 2>/dev/null + else + stat -f %m "$1" 2>/dev/null + fi +} diff --git a/bin/fm-supervision-lib.sh b/bin/fm-supervision-lib.sh index c89747a94f..c2922cd317 100644 --- a/bin/fm-supervision-lib.sh +++ b/bin/fm-supervision-lib.sh @@ -9,14 +9,10 @@ # fields here for its banner but performs its end-of-turn block decision with the # live watcher lock check in bin/fm-wake-lib.sh. -# Portable mtime; Linux stat lacks -f, macOS stat lacks -c. -fm_sup_stat_mtime() { - if [ "$(uname)" = Darwin ]; then - stat -f %m "$1" 2>/dev/null - else - stat -c %Y "$1" 2>/dev/null - fi -} +# Portable file-mtime read (fm_stat_mtime), capability-probed rather than +# OS-branched; see bin/fm-stat-lib.sh for why the uname branch was unsafe. +# shellcheck source=bin/fm-stat-lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/fm-stat-lib.sh" # fm_supervision_status [grace-seconds] # Populates, for the state dir at $1: @@ -40,7 +36,7 @@ fm_supervision_status() { beat="$state/.last-watcher-beat" if [ -e "$beat" ]; then - m=$(fm_sup_stat_mtime "$beat") + m=$(fm_stat_mtime "$beat") if [ -n "$m" ]; then age=$(( $(date +%s) - m )) FM_SUP_BEACON_DESC="${age}s ago" diff --git a/docs/scripts.md b/docs/scripts.md index 63dc138507..ff1824a545 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -55,6 +55,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-crew-state.sh` | Print one deterministic current-state line for a crew | | `fm-tangle-lib.sh` | Shared default-branch resolution and primary-checkout tangle classification | | `fm-supervision-lib.sh` | Shared in-flight-work-without-fresh-watcher-beacon predicate | +| `fm-stat-lib.sh` | Shared capability-probed portable file-mtime read (`fm_stat_mtime`), never OS-branched | | `fm-ff-lib.sh` | Shared guarded fast-forward helper for origin pulls and local secondmate syncs | | `fm-lock-lib.sh` | Shared "is this git lock provably abandoned?" proof used by teardown and fleet-sync | | `fm-config-inherit-lib.sh` | Shared primary-to-secondmate inherited local-material propagation | diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index c25bf8a225..203473e1e1 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -110,7 +110,7 @@ BASE_REF=$(resolve_base_ref) \ # tmux-only conformance run the tmux adapter's behavior is what is under test, # and that is unchanged by any later (e.g. non-tmux backend) addition to # fm-backend.sh's own dispatch surface. -OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tasks-axi-lib.sh fm-pr-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-marker-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-supervision-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-decision-hold.sh fm-backend.sh" +OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tasks-axi-lib.sh fm-pr-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-marker-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-supervision-lib.sh fm-stat-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-decision-hold.sh fm-backend.sh" OLD_BIN_REFACTORED="fm-send.sh fm-peek.sh fm-watch.sh fm-spawn.sh fm-teardown.sh" build_old_bin() { # -> echoes root dir (root/bin/