diff --git a/bin/fm-backlog-receive.sh b/bin/fm-backlog-receive.sh index 15d9bde99ae..be5b5326b96 100755 --- a/bin/fm-backlog-receive.sh +++ b/bin/fm-backlog-receive.sh @@ -26,6 +26,8 @@ LOCK_STALE_SECS=30 . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" # shellcheck source=bin/fm-wake-lib.sh . "$SCRIPT_DIR/fm-wake-lib.sh" +# shellcheck source=bin/fm-stat-lib.sh +. "$SCRIPT_DIR/fm-stat-lib.sh" die() { printf 'error: %s\n' "$1" >&2; exit 1; } usage() { sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit 2; } @@ -56,11 +58,7 @@ list_keys() { # lock_age() { local modified now - if [ "$(uname 2>/dev/null)" = Darwin ]; then - modified=$(stat -f '%m' "$1" 2>/dev/null) || return 1 - else - modified=$(stat -c '%Y' "$1" 2>/dev/null) || return 1 - fi + modified=$(fm_stat_mtime "$1") || return 1 now=$(date +%s) || return 1 case "$modified$now" in *[!0-9]*) return 1 ;; esac printf '%s\n' "$((now - modified))" diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index a903d7713ee..80461d1190e 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -35,6 +35,9 @@ _FM_CLASSIFY_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/null)" # or no-mistakes install; absent, it points at the real sibling script. FM_CREW_STATE_BIN="${FM_CREW_STATE_BIN:-$_FM_CLASSIFY_LIB_DIR/fm-crew-state.sh}" +# shellcheck source=bin/fm-stat-lib.sh +. "$_FM_CLASSIFY_LIB_DIR/fm-stat-lib.sh" + # Captain-relevant status verbs. A status line carrying any of these is work # firstmate must see. Lines without these verbs are no-verb signals: the watcher # absorbs them only with positive provably-working evidence, while the daemon uses @@ -346,14 +349,15 @@ _fm_open_decisions_cursor_path() { # printf '%s/.%s.open-decisions-cursor' "$dir" "${base%.status}" } -# Portable device:inode identity for the rotation/recreation check below. +# Portable device:inode identity for the rotation/recreation check below. The +# flavor comes from the binary's own dialect (bin/fm-stat-lib.sh), not from +# `uname -s`: a Darwin kernel routinely resolves `stat` to GNU coreutils, and a +# wrong flavor here makes every read look like a rotation. +# Non-zero (with empty stdout) on I/O failure: the caller at +# status_open_decisions_incremental keys its "trust the cursor" early return on +# that exit status, so it must survive. _fm_open_decisions_file_ident() { # -> "dev:inode", empty on I/O failure - local f=$1 - if [ "$(uname -s 2>/dev/null)" = Darwin ]; then - LC_ALL=C stat -f '%d:%i' "$f" 2>/dev/null - else - LC_ALL=C stat -c '%d:%i' "$f" 2>/dev/null - fi + fm_stat_identity "$1" } status_open_decisions_incremental() { # diff --git a/bin/fm-lock-lib.sh b/bin/fm-lock-lib.sh index 9162c0070f8..d2c53152063 100644 --- a/bin/fm-lock-lib.sh +++ b/bin/fm-lock-lib.sh @@ -21,6 +21,10 @@ # binary through the same function so one host can never answer "is lsof here?" # two different ways. +_FM_LOCK_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/null)" || _FM_LOCK_LIB_DIR="." +# shellcheck source=bin/fm-stat-lib.sh +. "$_FM_LOCK_LIB_DIR/fm-stat-lib.sh" + fm_lock_log() { echo "${FM_LOCK_LOG_PREFIX:-fm-lock}: $*" >&2 } @@ -54,14 +58,14 @@ fm_lsof_bin() { return 1 } -# Portable mtime in epoch seconds. Kept self-contained so this leaf lib drags in -# no wake-queue machinery when a caller only needs the staleness proof. +# Portable mtime in epoch seconds. Still drags in no wake-queue machinery when a +# caller only needs the staleness proof: fm-stat-lib.sh is a dependency-free leaf +# whose only job is answering which dialect this host's `stat` speaks. Asking +# `uname` instead is wrong - a Darwin kernel routinely resolves `stat` to GNU +# coreutils - and a wrong answer here reads every lock as unreadable, which fails +# safe but permanently refuses to reap an abandoned lock. fm_lock_path_mtime() { - if [ "$(uname)" = Darwin ]; then - stat -f %m "$1" 2>/dev/null - else - stat -c %Y "$1" 2>/dev/null - fi + fm_stat_mtime "$1" } # fm_lock_lsof_holder : 0 a process holds it, 1 provably none, 2 lsof diff --git a/bin/fm-pending-reply-lib.sh b/bin/fm-pending-reply-lib.sh index 50908b8206e..a24f7368b8a 100755 --- a/bin/fm-pending-reply-lib.sh +++ b/bin/fm-pending-reply-lib.sh @@ -72,6 +72,8 @@ _FM_PENDING_REPLY_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/n . "$_FM_PENDING_REPLY_LIB_DIR/fm-backend.sh" # shellcheck source=bin/fm-tmux-lib.sh . "$_FM_PENDING_REPLY_LIB_DIR/fm-tmux-lib.sh" +# shellcheck source=bin/fm-stat-lib.sh +. "$_FM_PENDING_REPLY_LIB_DIR/fm-stat-lib.sh" FM_PENDING_REPLY_SCHEMA='fm-pending-reply.v1' FM_PENDING_REPLY_CORR_RE='corr=[A-Fa-f0-9]{16}' @@ -462,11 +464,11 @@ fm_pending_reply_find_resolve_line() { # fm_pending_reply_file_signature() { # local path=$1 [ -f "$path" ] || { printf 'missing'; return 0; } - if [ "$(uname -s 2>/dev/null)" = Darwin ]; then - LC_ALL=C stat -f '%d:%i:%z:%m:%c' "$path" 2>/dev/null || printf 'unreadable' - else - LC_ALL=C stat -c '%d:%i:%s:%Y:%Z' "$path" 2>/dev/null || printf 'unreadable' - fi + # Field flavor from the binary's own dialect, not `uname -s`: a Darwin kernel + # routinely resolves `stat` to GNU coreutils, and the wrong flavor would pin + # every file at 'unreadable' - a signature that never changes, so no missed + # reply would ever be detected. See bin/fm-stat-lib.sh. + fm_stat_fingerprint "$path" || printf 'unreadable' } fm_pending_reply_status_set_signature() { # diff --git a/bin/fm-pr-lib.sh b/bin/fm-pr-lib.sh index b70d8468894..736f1e6489e 100755 --- a/bin/fm-pr-lib.sh +++ b/bin/fm-pr-lib.sh @@ -17,6 +17,10 @@ # The receipt binds the terminal observation to the canonical registration and # lets a restart finish fixed-path removal without executing state-file bytes. +_FM_PR_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/null)" || _FM_PR_LIB_DIR="." +# shellcheck source=bin/fm-stat-lib.sh +. "$_FM_PR_LIB_DIR/fm-stat-lib.sh" + FM_PR_PROVIDER= FM_PR_URL= FM_PR_HOST= @@ -213,37 +217,17 @@ fm_pr_head_valid() { [[ "$head" =~ ^[0-9a-f]{40}$|^[0-9a-f]{64}$ ]] } -fm_pr_file_mode() { - if [ "$(uname)" = Darwin ]; then - stat -f %Lp "$1" 2>/dev/null - else - stat -c %a "$1" 2>/dev/null - fi -} +# Field flavor comes from bin/fm-stat-lib.sh, which detects what this host's +# `stat` binary actually speaks. Keying on `uname` would be wrong: a Darwin +# kernel routinely resolves `stat` to GNU coreutils (nix-darwin, or Homebrew +# coreutils ahead of /usr/bin on PATH). +fm_pr_file_mode() { fm_stat_mode "$1"; } -fm_pr_file_device() { - if [ "$(uname)" = Darwin ]; then - stat -f %d "$1" 2>/dev/null - else - stat -c %d "$1" 2>/dev/null - fi -} +fm_pr_file_device() { fm_stat_device "$1"; } -fm_pr_file_link_count() { - if [ "$(uname)" = Darwin ]; then - stat -f %l "$1" 2>/dev/null - else - stat -c %h "$1" 2>/dev/null - fi -} +fm_pr_file_link_count() { fm_stat_links "$1"; } -fm_pr_file_inode() { - if [ "$(uname)" = Darwin ]; then - stat -f %i "$1" 2>/dev/null - else - stat -c %i "$1" 2>/dev/null - fi -} +fm_pr_file_inode() { fm_stat_inode "$1"; } fm_pr_file_identity() { local device inode diff --git a/bin/fm-remote-file.sh b/bin/fm-remote-file.sh index 34a993db5b4..99e0dc2b807 100755 --- a/bin/fm-remote-file.sh +++ b/bin/fm-remote-file.sh @@ -18,6 +18,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=bin/fm-wake-lib.sh . "$SCRIPT_DIR/fm-wake-lib.sh" +# shellcheck source=bin/fm-stat-lib.sh +. "$SCRIPT_DIR/fm-stat-lib.sh" die() { printf 'error: %s\n' "$1" >&2; exit 1; } usage() { sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 2; } @@ -75,13 +77,7 @@ snapshot_bounded_file() { # ) } -directory_identity() { - if [ "$(uname)" = Darwin ]; then - stat -f '%d:%i' . 2>/dev/null - else - stat -c '%d:%i' . 2>/dev/null - fi -} +directory_identity() { fm_stat_identity .; } put_handoff_file() { # local home_real=$1 name=$2 max=$3 rel=$4 expected_bytes=$5 expected_hash=$6 generation=$7 diff --git a/bin/fm-remote-inherit-push.sh b/bin/fm-remote-inherit-push.sh index f0d6f416d4c..bc761329a40 100755 --- a/bin/fm-remote-inherit-push.sh +++ b/bin/fm-remote-inherit-push.sh @@ -21,14 +21,14 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" . "$SCRIPT_DIR/fm-secondmate-registry-lib.sh" # shellcheck source=bin/fm-config-inherit-lib.sh . "$SCRIPT_DIR/fm-config-inherit-lib.sh" +# shellcheck source=bin/fm-stat-lib.sh +. "$SCRIPT_DIR/fm-stat-lib.sh" die() { printf 'error: %s\n' "$1" >&2; exit 1; } sha256_file() { if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}'; else sha256sum "$1" | awk '{print $1}'; fi } -file_link_count() { - if [ "$(uname)" = Darwin ]; then stat -f %l "$1" 2>/dev/null; else stat -c %h "$1" 2>/dev/null; fi -} +file_link_count() { fm_stat_links "$1"; } shared_captain_header_valid() { local head head=$(sed -n '1,12p' "$1" 2>/dev/null) || return 1 diff --git a/bin/fm-remote-inherit.sh b/bin/fm-remote-inherit.sh index be995d75c70..32670b6400b 100755 --- a/bin/fm-remote-inherit.sh +++ b/bin/fm-remote-inherit.sh @@ -18,12 +18,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" . "$SCRIPT_DIR/fm-wake-lib.sh" # shellcheck source=bin/fm-config-inherit-lib.sh . "$SCRIPT_DIR/fm-config-inherit-lib.sh" +# shellcheck source=bin/fm-stat-lib.sh +. "$SCRIPT_DIR/fm-stat-lib.sh" die() { printf 'error: %s\n' "$1" >&2; exit 1; } usage() { sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 2; } -file_link_count() { - if [ "$(uname)" = Darwin ]; then stat -f %l "$1" 2>/dev/null; else stat -c %h "$1" 2>/dev/null; fi -} +file_link_count() { fm_stat_links "$1"; } sha256_file() { if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}'; else sha256sum "$1" | awk '{print $1}'; fi } diff --git a/bin/fm-remote-job-lib.sh b/bin/fm-remote-job-lib.sh index 5a4732542c0..90169bffe7d 100755 --- a/bin/fm-remote-job-lib.sh +++ b/bin/fm-remote-job-lib.sh @@ -38,6 +38,10 @@ # an Aqua requirement. The launch-agent renderer and repair helpers here are # shared by the entrypoint and remote doctor so their ownership cannot drift. +_FM_REMOTE_JOB_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/null)" || _FM_REMOTE_JOB_LIB_DIR="." +# shellcheck source=bin/fm-stat-lib.sh +. "$_FM_REMOTE_JOB_LIB_DIR/fm-stat-lib.sh" + FM_REMOTE_JOB_LABEL=dev.firstmate.remote-job FM_REMOTE_JOB_MAX_BYTES=${FM_REMOTE_JOB_MAX_BYTES:-1048576} FM_REMOTE_JOB_QUEUE_TIMEOUT=${FM_REMOTE_JOB_QUEUE_TIMEOUT:-360} @@ -571,9 +575,11 @@ fm_remote_job_reap() { # ; only removes an exact completed re } fm_remote_job_path_mtime() { # - # The platform override controls worker shape in isolated tests, not the host - # kernel's stat syntax. - if [ "$(uname -s 2>/dev/null || true)" = Darwin ]; then stat -f %m "$1" 2>/dev/null; else stat -c %Y "$1" 2>/dev/null; fi + # Neither the platform override nor the host kernel decides stat's syntax: the + # `stat` BINARY does, and a Darwin kernel routinely resolves it to GNU + # coreutils. Getting this wrong made the readiness probe fail permanently + # (robots-xw8p). bin/fm-stat-lib.sh feature-detects the binary instead. + fm_stat_mtime "$1" } fm_remote_job_reap_stale() { # diff --git a/bin/fm-stat-lib.sh b/bin/fm-stat-lib.sh new file mode 100644 index 00000000000..9792e62dcd8 --- /dev/null +++ b/bin/fm-stat-lib.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# fm-stat-lib.sh - ONE owner for "which dialect does THIS host's `stat` speak?". +# +# `stat` has two incompatible flavors and firstmate runs on both: +# BSD/macOS stat -f %m mtime, %z size, %Lp mode, %l links, %i inode, %d device +# GNU stat -c %Y mtime, %s size, %a mode, %h links, %i inode, %d device +# +# Two ways of choosing between them are BOTH wrong, and firstmate has been bitten +# by each: +# +# 1. `uname` is the wrong discriminator. A Darwin kernel routinely resolves +# `stat` to GNU coreutils - nix-darwin, or Homebrew coreutils ahead of +# /usr/bin on PATH. The kernel says Darwin, the binary speaks GNU, and every +# `if [ "$(uname)" = Darwin ]` branch picks the flavor the binary does not +# speak. That is robots-xw8p (remote-job readiness probe) and robots-e8x5 +# (thirteen more call sites). +# +# 2. `stat -f ... || stat -c ...` is worse, and subtly: GNU's `-f` +# is --file-system, not --format. When `stat -f %m ` runs under GNU, +# `%m` becomes a second file operand; GNU stats the FILESYSTEM of , +# writes a multi-line apfs/ext4 dump to STDOUT, and then EXITS 1. The `||` +# DOES fire, and `2>/dev/null` only silences stderr - so the fallback's +# correct integer is APPENDED to the dump already in the pipe. The caller +# receives a non-integer multi-line value at overall rc=0: invisible to +# error-handling, fatal to any arithmetic that follows. +# +# So: FEATURE-DETECT the binary, once per process, and dispatch. The probe must +# try `-c` first: BSD stat rejects `-c` with a non-zero exit and NOTHING on +# stdout, so the clean failure falls through to the `-f` probe; GNU stat rejects +# `-f` only AFTER polluting stdout, so probing `-f` first would contribute junk +# output even when it fails. Probing `-c` first guarantees the discarded probe +# cannot contribute output under either flavor. +# +# The dialect is cached in _FM_STAT_DIALECT after the first probe, because +# callers like fm_path_mtime run inside 0.2s confirm and 0.5s attach polls where +# forking a probe per call is a measurable cost. +# +# No side effects on source. set -u / set -e safe. Leaf lib: depends on nothing. +# +# Tunables (env): +# FM_STAT_DIALECT_OVERRIDE force 'gnu' or 'bsd' (tests); skips the probe + +# fm_stat_dialect: prints 'gnu' or 'bsd'; non-zero when neither probe answers. +fm_stat_dialect() { + if [ -n "${FM_STAT_DIALECT_OVERRIDE:-}" ]; then + case "$FM_STAT_DIALECT_OVERRIDE" in + gnu|bsd) printf '%s\n' "$FM_STAT_DIALECT_OVERRIDE"; return 0 ;; + *) return 1 ;; + esac + fi + if [ -z "${_FM_STAT_DIALECT:-}" ]; then + local probe + # `/` is guaranteed to exist and to have an integer size under both flavors, + # so a bare-integer result is a positive identification of the dialect and + # anything else (empty, usage text, a filesystem dump) is a rejection. + probe=$(stat -c %s / 2>/dev/null) || probe='' + case "$probe" in + ''|*[!0-9]*) + probe=$(stat -f %z / 2>/dev/null) || probe='' + case "$probe" in + ''|*[!0-9]*) _FM_STAT_DIALECT=unknown ;; + *) _FM_STAT_DIALECT=bsd ;; + esac + ;; + *) _FM_STAT_DIALECT=gnu ;; + esac + fi + [ "$_FM_STAT_DIALECT" != unknown ] || return 1 + printf '%s\n' "$_FM_STAT_DIALECT" +} + +# fm_stat_fmt : print the formatted field, else fail. +# The two format strings are the SAME field expressed in each dialect; callers +# below name the field so no call site has to remember the letter pairs. +fm_stat_fmt() { + local gnu_fmt=$1 bsd_fmt=$2 path=$3 dialect out + dialect=$(fm_stat_dialect) || return 1 + case "$dialect" in + gnu) out=$(LC_ALL=C stat -c "$gnu_fmt" "$path" 2>/dev/null) || return 1 ;; + bsd) out=$(LC_ALL=C stat -f "$bsd_fmt" "$path" 2>/dev/null) || return 1 ;; + *) return 1 ;; + esac + [ -n "$out" ] || return 1 + printf '%s\n' "$out" +} + +# _fm_stat_uint : as fm_stat_fmt, but the result must +# be a bare unsigned integer. This is the belt to fm_stat_dialect's braces: if a +# host ever ships a third flavor that the probe misreads, a caller doing +# arithmetic gets a clean failure instead of a stray token. +_fm_stat_uint() { + local out + out=$(fm_stat_fmt "$1" "$2" "$3") || return 1 + case "$out" in + ''|*[!0-9]*) return 1 ;; + esac + printf '%s\n' "$out" +} + +fm_stat_mtime() { _fm_stat_uint %Y %m "$1"; } # mtime, epoch seconds +fm_stat_ctime() { _fm_stat_uint %Z %c "$1"; } # inode change time, epoch seconds +fm_stat_size() { _fm_stat_uint %s %z "$1"; } # size in bytes +fm_stat_mode() { _fm_stat_uint %a %Lp "$1"; } # permission bits, octal +fm_stat_device() { _fm_stat_uint %d %d "$1"; } # device number +fm_stat_inode() { _fm_stat_uint %i %i "$1"; } # inode number +fm_stat_links() { _fm_stat_uint %h %l "$1"; } # hard link count + +# fm_stat_identity : "device:inode" - the rotation/recreation check. +fm_stat_identity() { fm_stat_fmt '%d:%i' '%d:%i' "$1"; } + +# fm_stat_signature : "size:mtime" change signature. BSD %Fm is the +# fractional-second mtime; the GNU side stays whole-second %Y, matching the +# pairing every caller already used - a signature only has to differ when the +# file changes, it does not have to mean the same thing across hosts. +fm_stat_signature() { fm_stat_fmt '%s:%Y' '%z:%Fm' "$1"; } + +# fm_stat_fingerprint : "device:inode:size:mtime:ctime". +fm_stat_fingerprint() { fm_stat_fmt '%d:%i:%s:%Y:%Z' '%d:%i:%z:%m:%c' "$1"; } diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 9d20855202a..eba63ac5975 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -228,12 +228,15 @@ AFK_FLAG_NAME=".afk" # classifiers can take an explicit state arg without depending on globals. _state_root() { printf '%s' "${FM_STATE_OVERRIDE:-$FM_HOME/state}"; } -# --- portable stat (same trap as fm-watch.sh: no `stat -f || stat -c`) ------- -if [ "$(uname)" = Darwin ]; then - _stat_file_mtime() { stat -f %m "$1" 2>/dev/null; } -else - _stat_file_mtime() { stat -c %Y "$1" 2>/dev/null; } -fi +# --- portable stat: one owner, bin/fm-stat-lib.sh --------------------------- +# It closes both traps at once - never `stat -f || stat -c` (GNU's -f is +# --file-system: it writes a filesystem dump to STDOUT and only THEN exits 1, so +# the fallback DOES run and appends its correct integer to that dump, handing the +# caller a multi-line non-integer at overall rc=0), and never keyed on `uname` +# (a Darwin kernel routinely resolves `stat` to GNU coreutils). +# shellcheck source=bin/fm-stat-lib.sh +. "$FM_DAEMON_DIR/fm-stat-lib.sh" +_stat_file_mtime() { fm_stat_mtime "$1"; } _now() { date +%s; } _file_age() { # seconds since mtime; very large if missing local f=$1 m diff --git a/bin/fm-supervision-lib.sh b/bin/fm-supervision-lib.sh index 252d0c93c21..45e1784c867 100644 --- a/bin/fm-supervision-lib.sh +++ b/bin/fm-supervision-lib.sh @@ -1,6 +1,8 @@ # shellcheck shell=bash # Shared "supervision missing" predicate. # Usage: . bin/fm-supervision-lib.sh +# shellcheck source=bin/fm-stat-lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/fm-stat-lib.sh" # # Reports whether a firstmate home needs supervision because it has in-flight # work (a state/.meta exists) or an X-mode relay poll @@ -14,15 +16,6 @@ # identity-matched watcher is still required. The status fields here retain the # beacon-age details used in their messages. -# 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 -} - # fm_supervision_status [grace-seconds] # Populates, for the state dir at $1: # FM_SUP_IN_FLIGHT count of state/*.meta (in-flight tasks) @@ -60,7 +53,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/bin/fm-test-run.sh b/bin/fm-test-run.sh index 088c68b3fc4..1039ebb16f3 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -1609,6 +1609,15 @@ else if [ -s "$out" ]; then cat "$out" fi + # This runner is deliberately standalone (its own test copies just this file + # into a fixture repo), so it does not source bin/fm-stat-lib.sh - the shared + # dialect owner every other caller uses. The ordering here is load-bearing + # and must stay GNU-FIRST: BSD stat rejects `-c` with a usage error on stderr + # and writes nothing to stdout, so the fallback fires cleanly. Flipping it to + # BSD-first would break on a GNU-coreutils-on-Darwin host, because GNU `-f` + # is --file-system: it writes a filesystem dump to stdout and only THEN exits + # 1, so the `||` DOES fire and appends the real mode to that dump - `$mode` + # comes back a multi-line non-integer at rc=0 and every case arm misses. mode=$(stat -c %a "$work" 2>/dev/null || stat -f %Lp "$work" 2>/dev/null || echo unknown) case "$mode" in 700|0700) ;; diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh index 3af1642b319..2913f526d6d 100755 --- a/bin/fm-wake-lib.sh +++ b/bin/fm-wake-lib.sh @@ -9,10 +9,13 @@ STATE="${FM_STATE_OVERRIDE:-${STATE:-$FM_HOME/state}}" FM_WAKE_QUEUE="${FM_WAKE_QUEUE:-$STATE/.wake-queue}" FM_WAKE_QUEUE_LOCK="${FM_WAKE_QUEUE_LOCK:-$STATE/.wake-queue.lock}" FM_LOCK_STALE_AFTER="${FM_LOCK_STALE_AFTER:-2}" -# Resolved once at source time: fm_pid_identity and fm_path_mtime run inside 0.2s -# confirm and 0.5s attach polls, and forking uname per call is a measurable cost on -# the platform (Git Bash/MSYS) that already pays the highest fork price. +# Resolved once at source time: fm_pid_identity runs inside 0.2s confirm and 0.5s +# attach polls, and forking uname per call is a measurable cost on the platform +# (Git Bash/MSYS) that already pays the highest fork price. Note this is a KERNEL +# question only - fm_path_mtime deliberately does NOT use it, see below. _FM_UNAME=$(uname 2>/dev/null || echo unknown) +# shellcheck source=bin/fm-stat-lib.sh +. "$FM_WAKE_LIB_DIR/fm-stat-lib.sh" mkdir -p "$STATE" fm_current_pid() { @@ -64,12 +67,12 @@ fm_pid_identity() { printf '%s\n' "$out" | sed 's/^[[:space:]]*//' } +# Deliberately NOT keyed on $_FM_UNAME: a Darwin kernel routinely resolves `stat` +# to GNU coreutils, so the kernel's name does not predict the binary's dialect. +# bin/fm-stat-lib.sh feature-detects the binary once and caches the answer, so +# this stays fork-free per call in the confirm/attach polls. fm_path_mtime() { - if [ "$_FM_UNAME" = Darwin ]; then - stat -f %m "$1" 2>/dev/null - else - stat -c %Y "$1" 2>/dev/null - fi + fm_stat_mtime "$1" } fm_path_age() { diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 8058c715ce3..c6e50895a99 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -93,20 +93,23 @@ WATCHER_STALE_GRACE=${FM_WATCHER_STALE_GRACE:-${FM_GUARD_GRACE:-300}} # or starting the loop. Running it as a script executes the runtime exactly as # before, byte-for-byte. -# Portable stat. macOS (BSD) stat uses `-f `; Linux (GNU) stat uses `-c `. -# Do NOT use the `stat -f ... || stat -c ...` fallback form: on Linux -# `stat -f` is *filesystem* stat and writes a partial filesystem dump ("File: ...", -# "Blocks: ...") to stdout before failing, so the fallback's correct output gets -# appended to that garbage. Arithmetic under `set -u` then aborts on the stray -# token (e.g. the word "File" read as an unset variable), which silently kills the -# watcher mid-cycle. Detect the platform once and pick the right form. -if [ "$(uname)" = Darwin ]; then - stat_mtime() { stat -f %m "$1" 2>/dev/null; } # epoch seconds of mtime - stat_sig() { stat -f '%z:%Fm' "$1" 2>/dev/null; } # size:mtime signature -else - stat_mtime() { stat -c %Y "$1" 2>/dev/null; } - stat_sig() { stat -c '%s:%Y' "$1" 2>/dev/null; } -fi +# Portable stat, via the single owner in bin/fm-stat-lib.sh (sourced above through +# fm-pr-lib.sh, and again explicitly here because this file calls it directly). +# Two traps that lib exists to close, both of which used to live here: +# - `stat -f ... || stat -c ...` is unsafe, because GNU's `-f` is +# --file-system: it writes a filesystem dump to stdout and only THEN exits 1. +# The `||` does fire; that is the trap, not the escape. A single +# `$(... || ...)` captures both commands, so the fallback's correct integer +# lands appended to the dump at overall rc=0, and arithmetic under `set -u` +# aborts on a stray token and silently kills the watcher mid-cycle. +# - `uname` is the wrong discriminator. A Darwin kernel routinely resolves +# `stat` to GNU coreutils (nix-darwin, or Homebrew coreutils ahead of +# /usr/bin on PATH), so the kernel's name does not predict the binary's +# dialect. fm-stat-lib.sh feature-detects the binary once and caches it. +# shellcheck source=bin/fm-stat-lib.sh +. "$SCRIPT_DIR/fm-stat-lib.sh" +stat_mtime() { fm_stat_mtime "$1"; } # epoch seconds of mtime +stat_sig() { fm_stat_signature "$1"; } # size:mtime signature POLL=${FM_POLL:-15} # seconds between cycles HEARTBEAT=${FM_HEARTBEAT:-600} # base seconds between heartbeat scans diff --git a/bin/fm-x-lib.sh b/bin/fm-x-lib.sh index a8ea57991cd..17ebd6c89ac 100644 --- a/bin/fm-x-lib.sh +++ b/bin/fm-x-lib.sh @@ -48,6 +48,10 @@ # fmx_meta_link_clear - remove the X-request link entirely # Callers must have FM_HOME set before calling fmx_load_config. +_FM_X_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/null)" || _FM_X_LIB_DIR="." +# shellcheck source=bin/fm-stat-lib.sh +. "$_FM_X_LIB_DIR/fm-stat-lib.sh" + # Read the value of KEY from a .env-style file: last assignment wins; tolerates a # leading "export ", surrounding whitespace, and one layer of matching single or # double quotes. Prints nothing (and succeeds) when the file or key is absent, so @@ -90,13 +94,8 @@ fmx_poll_shim_v1_content() { fmx_single_link_file_valid() { local file=$1 expected_device=${2-} links device [ -f "$file" ] && [ ! -L "$file" ] || return 1 - if [ "$(uname)" = Darwin ]; then - links=$(stat -f %l "$file" 2>/dev/null) || return 1 - device=$(stat -f %d "$file" 2>/dev/null) || return 1 - else - links=$(stat -c %h "$file" 2>/dev/null) || return 1 - device=$(stat -c %d "$file" 2>/dev/null) || return 1 - fi + links=$(fm_stat_links "$file") || return 1 + device=$(fm_stat_device "$file") || return 1 [ "$links" = 1 ] || return 1 [ -z "$expected_device" ] || [ "$device" = "$expected_device" ] } @@ -104,24 +103,15 @@ fmx_single_link_file_valid() { fmx_single_link_file_mode_valid() { local file=$1 expected_mode=$2 expected_device=${3-} mode fmx_single_link_file_valid "$file" "$expected_device" || return 1 - if [ "$(uname)" = Darwin ]; then - mode=$(stat -f %Lp "$file" 2>/dev/null) || return 1 - else - mode=$(stat -c %a "$file" 2>/dev/null) || return 1 - fi + mode=$(fm_stat_mode "$file") || return 1 [ "$mode" = "$expected_mode" ] } fmx_private_artifact_dir_device() { local dir=$1 mode device [ -d "$dir" ] && [ ! -L "$dir" ] || return 1 - if [ "$(uname)" = Darwin ]; then - mode=$(stat -f %Lp "$dir" 2>/dev/null) || return 1 - device=$(stat -f %d "$dir" 2>/dev/null) || return 1 - else - mode=$(stat -c %a "$dir" 2>/dev/null) || return 1 - device=$(stat -c %d "$dir" 2>/dev/null) || return 1 - fi + mode=$(fm_stat_mode "$dir") || return 1 + device=$(fm_stat_device "$dir") || return 1 [ "$mode" = 700 ] || return 1 printf '%s\n' "$device" } @@ -417,12 +407,12 @@ fmx_request_relay_context() { # state under state/ and are pruned after the relay's 7-day follow-up window. fmx_context_registry_mtime() { - local file=$1 mtime - mtime=$(stat -f '%m' "$file" 2>/dev/null) || mtime=$(stat -c '%Y' "$file" 2>/dev/null) || return 1 - case "$mtime" in - ''|*[!0-9]*) return 1 ;; - esac - printf '%s\n' "$mtime" + # NOT `stat -f ... || stat -c ...`: on GNU coreutils `-f` is --file-system, so + # the first call writes a filesystem dump to STDOUT and only THEN exits 1. The + # fallback DOES run and appends its correct integer to that dump, so the caller + # gets a multi-line non-integer at overall rc=0 - invisible to error handling. + # fm-stat-lib.sh detects the binary's dialect instead. See bin/fm-stat-lib.sh. + fm_stat_mtime "$1" } fmx_context_registry_recorded_at() { diff --git a/docs/scripts.md b/docs/scripts.md index e9492beae58..4254a5e5349 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -100,6 +100,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-timeout-lib.sh` | Single owner of hard-bounded command execution and its fallback watchdog | | `fm-supervision-lib.sh` | Shared in-flight-work-without-fresh-watcher-beacon predicate | | `fm-ff-lib.sh` | Shared guarded fast-forward helper for origin pulls and local secondmate syncs | +| `fm-stat-lib.sh` | Feature-detect the `stat` binary's dialect (BSD vs GNU) once per process and dispatch accordingly; the single owner of portable `stat` calls across the fleet | | `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 and config-reread delivery | | `fm-tasks-axi-lib.sh` | Shared backlog-backend selector (tasks-axi/beads/manual), `tasks-axi` compatibility probe, and beads resolve-or-create for auto-linking | diff --git a/tests/fm-afk-return.test.sh b/tests/fm-afk-return.test.sh index aa3107440b2..cabaed2903b 100755 --- a/tests/fm-afk-return.test.sh +++ b/tests/fm-afk-return.test.sh @@ -18,6 +18,7 @@ install_runner() { # mkdir -p "$dir/bin" "$dir/home/state" "$dir/home/data" "$dir/home/config" cp "$ROOT/bin/fm-afk-return.sh" "$dir/bin/" cp "$ROOT/bin/fm-wake-lib.sh" "$dir/bin/" + cp "$ROOT/bin/fm-stat-lib.sh" "$dir/bin/" cp "$ROOT/bin/fm-classify-lib.sh" "$dir/bin/" cat > "$dir/bin/fm-afk-launch.sh" <<'SH' #!/usr/bin/env bash diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index fc6d7da54d4..62c40264f98 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -141,7 +141,7 @@ resolve_permissive_tmux_kill_ref() { # hence the dispatcher is a copied sibling, while the tmux adapter is extracted # from BASE_REF so conformance tests retain the exact historical behavior even # when this branch changes tmux dispatch semantics. -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-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-nm-run-lib.sh fm-decision-hold.sh fm-backend.sh fm-operational-input.sh fm-public-followup-lib.sh fm-secondmate-registry-lib.sh fm-secondmate-parent-lib.sh fm-x-lib.sh" +OLD_BIN_UNCHANGED_SIBLINGS="fm-stat-lib.sh 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-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-nm-run-lib.sh fm-decision-hold.sh fm-backend.sh fm-operational-input.sh fm-public-followup-lib.sh fm-secondmate-registry-lib.sh fm-secondmate-parent-lib.sh fm-x-lib.sh" # A pull-request merge may add a new main-only dependency that the branch's older baseline does not have yet. OLD_BIN_OPTIONAL_SIBLINGS="fm-pending-reply-lib.sh fm-beads-resilience-lib.sh" OLD_BIN_REFACTORED="fm-send.sh fm-peek.sh fm-watch.sh fm-spawn.sh fm-teardown.sh fm-marker-lib.sh" diff --git a/tests/fm-claude-stop-autoarm.test.sh b/tests/fm-claude-stop-autoarm.test.sh index 0d50bb95f84..34f63c7b460 100755 --- a/tests/fm-claude-stop-autoarm.test.sh +++ b/tests/fm-claude-stop-autoarm.test.sh @@ -30,6 +30,7 @@ install_autoarm_scripts() { cp "$ROOT/bin/fm-primary-scope-lib.sh" "$dir/bin/fm-primary-scope-lib.sh" cp "$ROOT/bin/fm-supervision-lib.sh" "$dir/bin/fm-supervision-lib.sh" cp "$ROOT/bin/fm-wake-lib.sh" "$dir/bin/fm-wake-lib.sh" + cp "$ROOT/bin/fm-stat-lib.sh" "$dir/bin/fm-stat-lib.sh" cp "$ROOT/bin/fm-watch-cycle-lib.sh" "$dir/bin/fm-watch-cycle-lib.sh" cp "$ROOT/bin/fm-session-lock-lib.sh" "$dir/bin/fm-session-lock-lib.sh" cp "$ROOT/bin/fm-lock.sh" "$dir/bin/fm-lock.sh" diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index 7be042d1085..941afc700d7 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -65,6 +65,10 @@ make_fake_root() { ln -s "$ROOT/bin/fm-gate-refuse-lib.sh" "$fake/bin/fm-gate-refuse-lib.sh" # fm-pr-lib.sh: teardown uses its canonical task-ID validator for poll cleanup. ln -s "$ROOT/bin/fm-pr-lib.sh" "$fake/bin/fm-pr-lib.sh" + # fm-stat-lib.sh: fm-pr-lib.sh and fm-lock-lib.sh both source it (stat-dialect + # owner), resolving the sibling from their own unresolved BASH_SOURCE dir, so it + # must exist next to them in the fake bin or the nested source fails under set -e. + ln -s "$ROOT/bin/fm-stat-lib.sh" "$fake/bin/fm-stat-lib.sh" # fm-public-followup-lib.sh (and the fm-x-lib.sh it sources): teardown sources # it for the relay-activation gate on the promised-public-reply check. Neither # does anything in this fixture, which has no .env, but both are real siblings @@ -147,6 +151,10 @@ test_teardown_skips_gracefully_without_tasktmp() { ln -s "$ROOT/bin/fm-gate-refuse-lib.sh" "$fake/bin/fm-gate-refuse-lib.sh" # fm-pr-lib.sh: teardown uses its canonical task-ID validator for poll cleanup. ln -s "$ROOT/bin/fm-pr-lib.sh" "$fake/bin/fm-pr-lib.sh" + # fm-stat-lib.sh: fm-pr-lib.sh and fm-lock-lib.sh both source it (stat-dialect + # owner), resolving the sibling from their own unresolved BASH_SOURCE dir, so it + # must exist next to them in the fake bin or the nested source fails under set -e. + ln -s "$ROOT/bin/fm-stat-lib.sh" "$fake/bin/fm-stat-lib.sh" # fm-public-followup-lib.sh (and the fm-x-lib.sh it sources): teardown sources # it for the relay-activation gate on the promised-public-reply check. Neither # does anything in this fixture, which has no .env, but both are real siblings diff --git a/tests/fm-on.test.sh b/tests/fm-on.test.sh index 790a56d5038..f1fd857d7ef 100755 --- a/tests/fm-on.test.sh +++ b/tests/fm-on.test.sh @@ -22,7 +22,7 @@ SSH_COUNT="$TMP_ROOT/ssh.count" mkdir -p "$LOCAL_HOME/data" "$REMOTE_ROOT/bin" "$REMOTE_HOME" printf 'fixture\n' > "$REMOTE_ROOT/AGENTS.md" cp "$ROOT/bin/fm-remote-entrypoint.sh" "$ROOT/bin/fm-remote-job-lib.sh" \ - "$ROOT/bin/fm-remote-job-worker.sh" "$REMOTE_ROOT/bin/" + "$ROOT/bin/fm-remote-job-worker.sh" "$ROOT/bin/fm-stat-lib.sh" "$REMOTE_ROOT/bin/" cat > "$REMOTE_ROOT/bin/fm-probe-one.sh" <<'SH' #!/usr/bin/env bash diff --git a/tests/fm-remote-backlog-handoff.test.sh b/tests/fm-remote-backlog-handoff.test.sh index 3206e2dd85c..b348206970f 100755 --- a/tests/fm-remote-backlog-handoff.test.sh +++ b/tests/fm-remote-backlog-handoff.test.sh @@ -22,7 +22,7 @@ printf 'fixture\n' > "$REMOTE_ROOT/AGENTS.md" cp "$ROOT/bin/fm-remote-entrypoint.sh" "$ROOT/bin/fm-remote-job-lib.sh" \ "$ROOT/bin/fm-remote-job-worker.sh" "$ROOT/bin/fm-remote-file.sh" \ "$ROOT/bin/fm-backlog-receive.sh" "$ROOT/bin/fm-tasks-axi-lib.sh" \ - "$ROOT/bin/fm-wake-lib.sh" "$REMOTE_ROOT/bin/" + "$ROOT/bin/fm-wake-lib.sh" "$ROOT/bin/fm-stat-lib.sh" "$REMOTE_ROOT/bin/" ln -s "$(command -v tasks-axi)" "$REMOTE_ROOT/bin/tasks-axi" ln -s "$(command -v node)" "$REMOTE_ROOT/bin/node" chmod +x "$REMOTE_ROOT/bin"/*.sh diff --git a/tests/fm-remote-entrypoint.test.sh b/tests/fm-remote-entrypoint.test.sh index ccbaa102601..0367dc95d41 100755 --- a/tests/fm-remote-entrypoint.test.sh +++ b/tests/fm-remote-entrypoint.test.sh @@ -12,7 +12,8 @@ TMP_ROOT=$(fm_test_tmproot fm-remote-entrypoint) REAL_BIN="$TMP_ROOT/real-root/bin" LOCAL_BIN="$TMP_ROOT/local-bin" mkdir -p "$REAL_BIN" "$LOCAL_BIN" -cp "$ROOT/bin/fm-remote-entrypoint.sh" "$ROOT/bin/fm-remote-job-lib.sh" "$REAL_BIN/" +cp "$ROOT/bin/fm-remote-entrypoint.sh" "$ROOT/bin/fm-remote-job-lib.sh" \ + "$ROOT/bin/fm-stat-lib.sh" "$REAL_BIN/" chmod +x "$REAL_BIN/fm-remote-entrypoint.sh" ln -s "$REAL_BIN/fm-remote-entrypoint.sh" "$LOCAL_BIN/fm-remote-entrypoint.sh" diff --git a/tests/fm-remote-job.test.sh b/tests/fm-remote-job.test.sh index 52364521952..38d8745a616 100755 --- a/tests/fm-remote-job.test.sh +++ b/tests/fm-remote-job.test.sh @@ -21,8 +21,13 @@ RECOVERY_WORKER_PID= mkdir -p "$REMOTE_ROOT/bin" "$REMOTE_HOME" "$ACCOUNT_HOME" "$RUNTIME_BIN" trap 'if [ -n "$OTHER_PID" ]; then kill "$OTHER_PID" 2>/dev/null || true; fi; if [ -n "$RECOVERY_WORKER_PID" ]; then kill "$RECOVERY_WORKER_PID" 2>/dev/null || true; fi; if [ -f "$STATE_ROOT/worker.pid" ]; then kill "$(cat "$STATE_ROOT/worker.pid")" 2>/dev/null || true; fi; rm -rf -- "$TMP_ROOT"' EXIT +# A real remote root is a full `git clone` of FM_ROOT (see +# bin/fm-remote-home-provision.sh), so a sibling lib is always present there. +# This fixture copies only the files the protocol touches, which means every +# sibling fm-remote-job-lib.sh sources has to be listed here too - +# fm-stat-lib.sh is one, and omitting it makes the worker fail to start. cp "$ROOT/bin/fm-remote-job-lib.sh" "$ROOT/bin/fm-remote-job-worker.sh" \ - "$ROOT/bin/fm-remote-delta-read.sh" "$REMOTE_ROOT/bin/" + "$ROOT/bin/fm-remote-delta-read.sh" "$ROOT/bin/fm-stat-lib.sh" "$REMOTE_ROOT/bin/" printf 'fixture\n' > "$REMOTE_ROOT/AGENTS.md" cat > "$REMOTE_ROOT/bin/fm-probe-job.sh" <<'SH' #!/bin/bash diff --git a/tests/fm-session-lock-ancestry.test.sh b/tests/fm-session-lock-ancestry.test.sh index 2f2e5094a47..5e08c77453d 100755 --- a/tests/fm-session-lock-ancestry.test.sh +++ b/tests/fm-session-lock-ancestry.test.sh @@ -229,6 +229,7 @@ install_autoarm_scripts() { cp "$ROOT/bin/fm-primary-scope-lib.sh" "$dir/bin/fm-primary-scope-lib.sh" cp "$ROOT/bin/fm-supervision-lib.sh" "$dir/bin/fm-supervision-lib.sh" cp "$ROOT/bin/fm-wake-lib.sh" "$dir/bin/fm-wake-lib.sh" + cp "$ROOT/bin/fm-stat-lib.sh" "$dir/bin/fm-stat-lib.sh" cp "$ROOT/bin/fm-session-lock-lib.sh" "$dir/bin/fm-session-lock-lib.sh" cp "$ROOT/bin/fm-lock.sh" "$dir/bin/fm-lock.sh" chmod +x "$dir/bin/fm-claude-stop-autoarm.sh" "$dir/bin/fm-lock.sh" diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 96671535c58..3b5ed65db94 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -131,6 +131,7 @@ install_guard_scripts() { cp "$ROOT/bin/fm-primary-scope-lib.sh" "$dir/bin/fm-primary-scope-lib.sh" cp "$ROOT/bin/fm-supervision-lib.sh" "$dir/bin/fm-supervision-lib.sh" cp "$ROOT/bin/fm-wake-lib.sh" "$dir/bin/fm-wake-lib.sh" + cp "$ROOT/bin/fm-stat-lib.sh" "$dir/bin/fm-stat-lib.sh" cp "$ROOT/bin/fm-watch-cycle-lib.sh" "$dir/bin/fm-watch-cycle-lib.sh" mkdir -p "$dir/docs" cp -R "$ROOT/docs/supervision-protocols" "$dir/docs/supervision-protocols" @@ -1090,6 +1091,7 @@ install_integrated_autoarm() { cp "$ROOT/bin/fm-primary-scope-lib.sh" "$dir/bin/fm-primary-scope-lib.sh" cp "$ROOT/bin/fm-supervision-lib.sh" "$dir/bin/fm-supervision-lib.sh" cp "$ROOT/bin/fm-wake-lib.sh" "$dir/bin/fm-wake-lib.sh" + cp "$ROOT/bin/fm-stat-lib.sh" "$dir/bin/fm-stat-lib.sh" cp "$ROOT/bin/fm-session-lock-lib.sh" "$dir/bin/fm-session-lock-lib.sh" cp "$ROOT/bin/fm-lock.sh" "$dir/bin/fm-lock.sh" chmod +x "$dir/bin/fm-claude-stop-autoarm.sh" "$dir/bin/fm-lock.sh" diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 29953fa68c1..14c2b677c48 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -65,10 +65,22 @@ wait_numeric_file() { return 1 } -# Portable mtime in epoch seconds. Platform-detected, never the `stat -f || stat -c` -# fallback (which writes a partial filesystem dump on Linux; see fm-watch.sh). +# Portable mtime in epoch seconds. This file is a standalone test, so it does not +# source bin/fm-stat-lib.sh (the shared dialect owner); the ordering below is +# load-bearing instead and must stay GNU-FIRST for the two reasons that lib +# exists to document: +# - `uname` is the wrong discriminator: a Darwin kernel routinely resolves +# `stat` to GNU coreutils (nix-darwin, or Homebrew coreutils ahead of +# /usr/bin on PATH), so the kernel's name does not predict the binary's +# dialect. +# - BSD-first is worse, not safer: GNU's `-f` is --file-system, so `stat -f %m` +# writes a filesystem dump to stdout and only THEN exits 1. The `||` does +# fire, but its correct integer is appended to the dump already in the pipe, +# handing the caller a multi-line non-integer at overall rc=0. +# GNU-first is clean because BSD stat rejects `-c` with a usage error on stderr +# and writes NOTHING to stdout, so the fallback fires with an empty pipe. file_mtime() { - if [ "$(uname)" = Darwin ]; then stat -f %m "$1" 2>/dev/null; else stat -c %Y "$1" 2>/dev/null; fi + stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null } # Set 's mtime to exactly seconds, for aging a busy-turn marker by