From 9e6bbb891c060aa6cc551ef04e5b8e90e62f7ef9 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:38:09 +0000 Subject: [PATCH 1/4] fix: harden test cleanup and wake locks --- bin/fm-afk-return.sh | 2 +- bin/fm-guard.sh | 7 +- bin/fm-pr-check-migrate.sh | 6 ++ bin/fm-supervise-daemon.sh | 8 +- bin/fm-wake-drain.sh | 2 +- bin/fm-wake-lib.sh | 102 ++++++++++++++++---- bin/fm-watch.sh | 8 +- tests/fm-afk-pi-herdr-return-e2e.test.sh | 3 +- tests/fm-afk-return.test.sh | 2 +- tests/fm-arm-pretool-check.test.sh | 6 +- tests/fm-axi-suite.test.sh | 3 +- tests/fm-backend-cmux.test.sh | 2 +- tests/fm-backend-herdr.test.sh | 3 +- tests/fm-backend-orca.test.sh | 2 +- tests/fm-backend-zellij.test.sh | 2 +- tests/fm-backend.test.sh | 2 +- tests/fm-backlog-handoff.test.sh | 2 +- tests/fm-bearings-snapshot.test.sh | 2 +- tests/fm-bootstrap.test.sh | 3 +- tests/fm-brief.test.sh | 3 +- tests/fm-cd-pretool-check.test.sh | 3 +- tests/fm-composer-ghost.test.sh | 2 +- tests/fm-crew-state.test.sh | 3 +- tests/fm-daemon.test.sh | 2 +- tests/fm-decision-hold-lifecycle.test.sh | 3 +- tests/fm-dispatch-select.test.sh | 3 +- tests/fm-ensure-agents-md.test.sh | 2 +- tests/fm-firstmate-update-check.test.sh | 2 +- tests/fm-fleet-snapshot-view.test.sh | 2 +- tests/fm-fleet-sync.test.sh | 3 +- tests/fm-fork-sync-check.test.sh | 2 +- tests/fm-gate-refuse.test.sh | 3 +- tests/fm-grok-harness.test.sh | 2 +- tests/fm-guard-stale-banner.test.sh | 2 +- tests/fm-herdr-lab.test.sh | 3 +- tests/fm-lint.test.sh | 8 +- tests/fm-pi-watch-extension.test.sh | 3 +- tests/fm-pr-check-security.test.sh | 3 +- tests/fm-pr-merge.test.sh | 2 +- tests/fm-review-diff.test.sh | 2 +- tests/fm-secondmate-harness.test.sh | 3 +- tests/fm-secondmate-lifecycle-e2e.test.sh | 3 +- tests/fm-secondmate-liveness.test.sh | 2 +- tests/fm-secondmate-safety.test.sh | 3 +- tests/fm-secondmate-sync.test.sh | 3 +- tests/fm-self-drift.test.sh | 3 +- tests/fm-send-popup-settle.test.sh | 2 +- tests/fm-send-secondmate-marker.test.sh | 2 +- tests/fm-send-settle.test.sh | 2 +- tests/fm-send-strict.test.sh | 2 +- tests/fm-session-start.test.sh | 3 +- tests/fm-sessionstart-nudge.test.sh | 3 +- tests/fm-shared-captain-inheritance.test.sh | 2 +- tests/fm-spawn-batch.test.sh | 3 +- tests/fm-spawn-dispatch-profile.test.sh | 3 +- tests/fm-supervision-events.test.sh | 3 +- tests/fm-supervision-instructions.test.sh | 3 +- tests/fm-tangle-guard.test.sh | 3 +- tests/fm-teardown.test.sh | 3 +- tests/fm-test-lib.test.sh | 78 +++++++++++++++ tests/fm-turnend-guard.test.sh | 3 +- tests/fm-update.test.sh | 2 +- tests/fm-wake-daemon-lifecycle-e2e.test.sh | 2 +- tests/fm-wake-queue.test.sh | 29 +++++- tests/fm-watch-bridge-inbox.test.sh | 2 +- tests/fm-watch-checkpoint.test.sh | 2 +- tests/fm-watch-triage.test.sh | 2 +- tests/fm-watcher-lock.test.sh | 53 +++++++++- tests/fm-x-mode.test.sh | 2 +- tests/lib.sh | 45 +++++++-- tests/wake-helpers.sh | 10 +- 71 files changed, 401 insertions(+), 105 deletions(-) create mode 100755 tests/fm-test-lib.test.sh diff --git a/bin/fm-afk-return.sh b/bin/fm-afk-return.sh index 23537e3b6..c6299d300 100755 --- a/bin/fm-afk-return.sh +++ b/bin/fm-afk-return.sh @@ -205,7 +205,7 @@ main() { . "$SCRIPT_DIR/fm-classify-lib.sh" mkdir -p "$STATE" || return 1 - fm_lock_acquire_wait "$LOCK" + fm_lock_acquire_wait "$LOCK" || return "$?" trap 'fm_lock_release "$LOCK"' EXIT write_pending_seed || { fm_lock_release "$LOCK"; trap - EXIT; return 1; } return_reconcile diff --git a/bin/fm-guard.sh b/bin/fm-guard.sh index 024491def..dbff6c9fe 100755 --- a/bin/fm-guard.sh +++ b/bin/fm-guard.sh @@ -63,7 +63,7 @@ fm_guard_claim_stale_banner() { local state=$1 key=$2 local marker="$state/.guard-watcher-stale-banner" local lock="$state/.guard-watcher-stale-banner.lock" - local seen i + local seen i rc seen=$(cat "$marker" 2>/dev/null || true) # Strip a single trailing newline so key comparison is line-content based. @@ -85,7 +85,12 @@ fm_guard_claim_stale_banner() { printf '%s\n' "$key" > "$marker" || true fm_lock_release "$lock" 2>/dev/null || true return 0 + else + rc=$? fi + # A filesystem failure is already reported by the lock helper. Keep the + # operator-facing alarm loud without retrying the broken operation 50 times. + [ "$rc" -eq 2 ] && return 0 seen=$(cat "$marker" 2>/dev/null || true) seen=${seen%$'\n'} if [ "$seen" = "$key" ]; then diff --git a/bin/fm-pr-check-migrate.sh b/bin/fm-pr-check-migrate.sh index a326d81d3..23987009f 100755 --- a/bin/fm-pr-check-migrate.sh +++ b/bin/fm-pr-check-migrate.sh @@ -292,6 +292,12 @@ while [ "$i" -lt 100 ]; do if fm_lock_try_acquire "$WATCH_LOCK"; then lock_held=1 break + else + lock_rc=$? + fi + if [ "$lock_rc" -eq 2 ]; then + echo "PR_CHECK_MIGRATION: watcher exclusion lock could not be created" >&2 + exit 1 fi # A concurrent migration may have completed while this process waited. # Its validated marker proves the old watcher crossed the boundary, so this diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index f9dc9c192..da6d7b5e5 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -1264,7 +1264,13 @@ fm_super_main() { [ -x "$WATCH" ] || { echo "error: watcher not found or not executable: $WATCH" >&2; exit 1; } # --- single instance (portable lock, no flock dependency) ------------------ - if ! fm_lock_try_acquire "$LOCK"; then + local lock_rc=0 + fm_lock_try_acquire "$LOCK" || lock_rc=$? + if [ "$lock_rc" -ne 0 ]; then + if [ "$lock_rc" -eq 2 ]; then + echo "error: supervise-daemon lock acquisition failed for $LOCK" >&2 + exit 1 + fi if [ -n "${FM_LOCK_HELD_PID:-}" ]; then echo "error: another fm-supervise-daemon is already running (pid $FM_LOCK_HELD_PID, lock $LOCK held)" >&2 else diff --git a/bin/fm-wake-drain.sh b/bin/fm-wake-drain.sh index ceaf13d3b..c3131987c 100755 --- a/bin/fm-wake-drain.sh +++ b/bin/fm-wake-drain.sh @@ -40,7 +40,7 @@ trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM -fm_lock_acquire_wait "$FM_WAKE_QUEUE_LOCK" +fm_lock_acquire_wait "$FM_WAKE_QUEUE_LOCK" || exit "$?" DRAIN_LOCK_HELD=true if [ ! -s "$FM_WAKE_QUEUE" ]; then diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh index c0e27b1e2..c68327ba9 100755 --- a/bin/fm-wake-lib.sh +++ b/bin/fm-wake-lib.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash # Shared durable wake queue and portable lock helpers. +# Lock acquisition returns 0 when acquired, 1 for genuine contention, and 2 for +# an operational filesystem failure; FM_LOCK_ERROR describes the latter. FM_WAKE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_WAKE_DEFAULT_ROOT="$(cd "$FM_WAKE_LIB_DIR/.." && pwd)" @@ -9,6 +11,7 @@ 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}" +FM_LOCK_STEAL_MAX_DEPTH="${FM_LOCK_STEAL_MAX_DEPTH:-8}" mkdir -p "$STATE" fm_current_pid() { @@ -157,12 +160,12 @@ fm_lock_claim() { mypid=${BASHPID:-$$} if ! { printf '%s\n' "$mypid" > "$ownerdir/pid"; } 2>/dev/null; then fm_lock_discard_owner "$ownerdir" - return 1 + return 2 fi back=$(cat "$ownerdir/pid" 2>/dev/null || true) if [ "$back" != "$mypid" ]; then fm_lock_discard_owner "$ownerdir" - return 1 + return 2 fi if ! fm_lock_points_to_owner "$lockdir" "$ownerdir"; then fm_lock_discard_owner "$ownerdir" @@ -179,29 +182,49 @@ fm_lock_claim() { } fm_lock_try_create() { - local lockdir=$1 allowed_steal_owner=${2:-} ownerdir + local lockdir=$1 allowed_steal_owner=${2:-} ownerdir claim_rc FM_LOCK_OWNER_DIR= - ownerdir=$(fm_lock_owner_dir "$lockdir") || return 1 + if ! ownerdir=$(fm_lock_owner_dir "$lockdir"); then + FM_LOCK_ERROR="could not create owner directory for $lockdir" + return 2 + fi if [ -e "$lockdir" ] || [ -L "$lockdir" ]; then fm_lock_discard_owner "$ownerdir" return 1 fi if ! fm_lock_prepare_owner "$ownerdir"; then fm_lock_discard_owner "$ownerdir" - return 1 + FM_LOCK_ERROR="could not prepare owner directory for $lockdir" + return 2 fi - if ln -s "$ownerdir" "$lockdir" 2>/dev/null && fm_lock_points_to_owner "$lockdir" "$ownerdir"; then - if fm_lock_claim "$lockdir" "$ownerdir" "$allowed_steal_owner"; then - FM_LOCK_OWNER_DIR=$ownerdir - return 0 - fi - if fm_lock_points_to_owner "$lockdir" "$ownerdir"; then - rm -f "$lockdir" 2>/dev/null || true + if ! ln -s "$ownerdir" "$lockdir" 2>/dev/null; then + fm_lock_remove_stray_owner_link "$lockdir" "$ownerdir" + fm_lock_discard_owner "$ownerdir" + if [ -e "$lockdir" ] || [ -L "$lockdir" ]; then + return 1 fi - else + FM_LOCK_ERROR="could not publish lock $lockdir" + return 2 + fi + if ! fm_lock_points_to_owner "$lockdir" "$ownerdir"; then fm_lock_remove_stray_owner_link "$lockdir" "$ownerdir" + fm_lock_discard_owner "$ownerdir" + return 1 + fi + if fm_lock_claim "$lockdir" "$ownerdir" "$allowed_steal_owner"; then + FM_LOCK_OWNER_DIR=$ownerdir + return 0 + else + claim_rc=$? + fi + if fm_lock_points_to_owner "$lockdir" "$ownerdir"; then + rm -f "$lockdir" 2>/dev/null || true fi fm_lock_discard_owner "$ownerdir" + if [ "$claim_rc" -eq 2 ]; then + FM_LOCK_ERROR="could not record owner for $lockdir" + return 2 + fi return 1 } @@ -248,13 +271,18 @@ fm_lock_recheck_stale_owner() { return 0 } -fm_lock_try_acquire() { - local lockdir=$1 pid steal cur rc steal_owner primary_owner +fm_lock_try_acquire_inner() { + local lockdir=$1 depth=$2 pid steal cur rc steal_owner primary_owner max_depth create_rc FM_LOCK_HELD_PID= FM_LOCK_OWNER_DIR= if fm_lock_try_create "$lockdir"; then return 0 + else + create_rc=$? + fi + if [ "$create_rc" -eq 2 ]; then + return 2 fi pid=$(cat "$lockdir/pid" 2>/dev/null || true) @@ -267,11 +295,23 @@ fm_lock_try_acquire() { return 1 fi + max_depth=$FM_LOCK_STEAL_MAX_DEPTH + case "$max_depth" in + ''|*[!0-9]*) max_depth=8 ;; + esac + if [ "$depth" -ge "$max_depth" ]; then + FM_LOCK_ERROR="steal depth exceeded $max_depth while acquiring $lockdir" + return 2 + fi + steal="$lockdir.steal" - if ! fm_lock_try_acquire "$steal"; then + if fm_lock_try_acquire_inner "$steal" "$((depth + 1))"; then + : + else + rc=$? FM_LOCK_HELD_PID=$(cat "$lockdir/pid" 2>/dev/null || true) FM_LOCK_OWNER_DIR= - return 1 + return "$rc" fi steal_owner=${FM_LOCK_OWNER_DIR:-} @@ -311,6 +351,8 @@ fm_lock_try_acquire() { rc=1 if fm_lock_try_create "$lockdir" "$steal_owner"; then rc=0 + else + rc=$? fi if [ "$rc" -ne 0 ]; then # shellcheck disable=SC2034 # Read by callers after fm_lock_try_acquire returns. @@ -321,9 +363,29 @@ fm_lock_try_acquire() { return "$rc" } +fm_lock_try_acquire() { + local lockdir=$1 rc + FM_LOCK_ERROR= + if fm_lock_try_acquire_inner "$lockdir" 0; then + return 0 + else + rc=$? + fi + if [ "$rc" -eq 2 ]; then + printf 'fm_lock_try_acquire: %s\n' "${FM_LOCK_ERROR:-filesystem operation failed for $lockdir}" >&2 + fi + return "$rc" +} + fm_lock_acquire_wait() { - local lockdir=$1 - while ! fm_lock_try_acquire "$lockdir"; do + local lockdir=$1 rc + while :; do + if fm_lock_try_acquire "$lockdir"; then + return 0 + else + rc=$? + fi + [ "$rc" -eq 1 ] || return "$rc" sleep 0.1 done } @@ -364,7 +426,7 @@ fm_wake_append() { seq_file="$STATE/.wake-queue.seq" status=0 - fm_lock_acquire_wait "$FM_WAKE_QUEUE_LOCK" + fm_lock_acquire_wait "$FM_WAKE_QUEUE_LOCK" || return "$?" seq=$(cat "$seq_file" 2>/dev/null || echo 0) case "$seq" in ''|*[!0-9]*) seq=0 ;; diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 9972b2abe..4f2294f56 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -744,7 +744,13 @@ fi exit 1 } -if ! fm_lock_try_acquire "$WATCH_LOCK"; then +lock_rc=0 +fm_lock_try_acquire "$WATCH_LOCK" || lock_rc=$? +if [ "$lock_rc" -ne 0 ]; then + if [ "$lock_rc" -eq 2 ]; then + echo "watcher: lock acquisition failed for $WATCH_LOCK" >&2 + exit 1 + fi BEAT="$STATE/.last-watcher-beat" if [ -n "${FM_LOCK_HELD_PID:-}" ]; then if [ -e "$BEAT" ]; then diff --git a/tests/fm-afk-pi-herdr-return-e2e.test.sh b/tests/fm-afk-pi-herdr-return-e2e.test.sh index 12b421351..eb1273d4e 100755 --- a/tests/fm-afk-pi-herdr-return-e2e.test.sh +++ b/tests/fm-afk-pi-herdr-return-e2e.test.sh @@ -31,7 +31,8 @@ done LAB_HELPER=${HERDR_LAB_HELPER:-$ROOT/bin/fm-herdr-lab.sh} SESSION=$("$LAB_HELPER" name fm-afk-pi-return-e2e) -TMP_ROOT=$(fm_test_tmproot fm-afk-pi-return-e2e) +fm_test_tmproot TMP_ROOT fm-afk-pi-return-e2e + HOME_DIR="$TMP_ROOT/home" STATE="$HOME_DIR/state" PROJECT="$TMP_ROOT/project" diff --git a/tests/fm-afk-return.test.sh b/tests/fm-afk-return.test.sh index a7e1a6888..6c1b383a0 100755 --- a/tests/fm-afk-return.test.sh +++ b/tests/fm-afk-return.test.sh @@ -11,7 +11,7 @@ set -u # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" -TMP_ROOT=$(fm_test_tmproot fm-afk-return-tests) +fm_test_tmproot TMP_ROOT fm-afk-return-tests install_runner() { # local dir=$1 diff --git a/tests/fm-arm-pretool-check.test.sh b/tests/fm-arm-pretool-check.test.sh index 52345bd43..738ed27af 100755 --- a/tests/fm-arm-pretool-check.test.sh +++ b/tests/fm-arm-pretool-check.test.sh @@ -142,8 +142,8 @@ matrix_case E16 allow '~/firstmate/bin/fm-watch-checkpoint.sh --seconds 180' matrix_case E17 allow 'for f in 1; do echo fm-watch; done' MATRIX_TMP=$(mktemp -d "${TMPDIR:-/tmp}/fm-arm-policy-matrix.XXXXXX") +fm_test_install_cleanup_traps FM_TEST_CLEANUP_DIRS+=("$MATRIX_TMP") -trap fm_test_cleanup EXIT run_matrix_entry() { local id=$1 expected=$2 entry=$3 cmd=$4 payload out_file err_file rc @@ -371,7 +371,7 @@ test_failopen_garbage_stdin() { test_failopen_missing_jq() { local dir fakebin rc real - dir=$(fm_test_tmproot fm-arm-pretool-check) + fm_test_tmproot dir fm-arm-pretool-check fakebin="$dir/fakebin" mkdir -p "$fakebin" local tool @@ -387,7 +387,7 @@ test_failopen_missing_jq() { test_failopen_missing_node() { local dir fakebin rc real tool - dir=$(fm_test_tmproot fm-arm-pretool-node) + fm_test_tmproot dir fm-arm-pretool-node fakebin="$dir/fakebin" mkdir -p "$fakebin" for tool in bash dirname; do diff --git a/tests/fm-axi-suite.test.sh b/tests/fm-axi-suite.test.sh index 174955307..91be72e77 100755 --- a/tests/fm-axi-suite.test.sh +++ b/tests/fm-axi-suite.test.sh @@ -5,7 +5,8 @@ set -u # shellcheck source=tests/lib.sh disable=SC1091 . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" -TMP_ROOT=$(fm_test_tmproot fm-axi-suite-tests) +fm_test_tmproot TMP_ROOT fm-axi-suite-tests + BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} make_tool() { diff --git a/tests/fm-backend-cmux.test.sh b/tests/fm-backend-cmux.test.sh index 79c16b13a..1111a8e82 100755 --- a/tests/fm-backend-cmux.test.sh +++ b/tests/fm-backend-cmux.test.sh @@ -15,7 +15,7 @@ set -u command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the cmux adapter)"; exit 0; } -TMP_ROOT=$(fm_test_tmproot fm-backend-cmux-tests) +fm_test_tmproot TMP_ROOT fm-backend-cmux-tests # make_cmux_fakebin: a `cmux` stub that logs every invocation (one line, # unit-separated args, to $FM_CMUX_LOG) and returns the canned response for diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index 5186cca2c..b4764cbe3 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -15,7 +15,8 @@ set -u command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the herdr adapter)"; exit 0; } -TMP_ROOT=$(fm_test_tmproot fm-backend-herdr-tests) +fm_test_tmproot TMP_ROOT fm-backend-herdr-tests + export FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP=0 # make_herdr_fakebin: a `herdr` stub that logs every invocation (one line, diff --git a/tests/fm-backend-orca.test.sh b/tests/fm-backend-orca.test.sh index e0bce69cf..44219e2cc 100755 --- a/tests/fm-backend-orca.test.sh +++ b/tests/fm-backend-orca.test.sh @@ -6,7 +6,7 @@ set -u # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" -TMP_ROOT=$(fm_test_tmproot fm-backend-orca-tests) +fm_test_tmproot TMP_ROOT fm-backend-orca-tests make_orca_fakebin() { # -> echoes fakebin dir local fb="$1/fakebin" diff --git a/tests/fm-backend-zellij.test.sh b/tests/fm-backend-zellij.test.sh index d7956338d..96ad78e16 100755 --- a/tests/fm-backend-zellij.test.sh +++ b/tests/fm-backend-zellij.test.sh @@ -14,7 +14,7 @@ set -u command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the zellij adapter)"; exit 0; } -TMP_ROOT=$(fm_test_tmproot fm-backend-zellij-tests) +fm_test_tmproot TMP_ROOT fm-backend-zellij-tests # make_zellij_fakebin: a `zellij` stub that logs every invocation (one line, # unit-separated args, to $FM_ZELLIJ_LOG) and returns the canned response for diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index c25bf8a22..33ac12286 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -33,7 +33,7 @@ fm_git_identity fmtest fmtest@example.invalid # shellcheck source=bin/fm-backend.sh . "$ROOT/bin/fm-backend.sh" -TMP_ROOT=$(fm_test_tmproot fm-backend-tests) +fm_test_tmproot TMP_ROOT fm-backend-tests # fm_backend_detect's cmux fallback (bundle id + process ancestry, # docs/cmux-backend.md "Runtime auto-detection") consults uname, lsappinfo, diff --git a/tests/fm-backlog-handoff.test.sh b/tests/fm-backlog-handoff.test.sh index b0b931d71..4a0d572c3 100755 --- a/tests/fm-backlog-handoff.test.sh +++ b/tests/fm-backlog-handoff.test.sh @@ -13,7 +13,7 @@ set -u # binary. Skip cleanly when it is absent (matching the backend smoke suites). command -v tasks-axi >/dev/null 2>&1 || { echo "skip: tasks-axi not found (required by the delegated handoff path)"; exit 0; } -TMP_ROOT=$(fm_test_tmproot fm-backlog-handoff) +fm_test_tmproot TMP_ROOT fm-backlog-handoff setup_homes() { local home=$1 subhome=$2 id=${3:-design} diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 0f19b2378..1e5563a48 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -11,7 +11,7 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" BEARINGS="$ROOT/bin/fm-bearings-snapshot.sh" -TMP_ROOT=$(fm_test_tmproot fm-bearings) +fm_test_tmproot TMP_ROOT fm-bearings command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 851ba66ec..58fb40c5c 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -21,7 +21,8 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} -TMP_ROOT=$(fm_test_tmproot fm-bootstrap-tests) +fm_test_tmproot TMP_ROOT fm-bootstrap-tests + export FM_BACKEND_CMUX_BUNDLE_BIN="$TMP_ROOT/no-bundled-cmux" # Hermetic runtime-backend detection. These cases pin the backend per-home via diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index 4a55fa182..870473afe 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -14,7 +14,8 @@ set -u # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" -TMP_ROOT=$(fm_test_tmproot fm-brief) +fm_test_tmproot TMP_ROOT fm-brief + BRIEF_HOME="$TMP_ROOT/home" mkdir -p "$BRIEF_HOME/data" diff --git a/tests/fm-cd-pretool-check.test.sh b/tests/fm-cd-pretool-check.test.sh index ae76f8d98..e160dda44 100755 --- a/tests/fm-cd-pretool-check.test.sh +++ b/tests/fm-cd-pretool-check.test.sh @@ -17,7 +17,7 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" fm_git_identity fmtest fmtest@example.invalid -TMP_ROOT=$(fm_test_tmproot fm-cd-pretool-check) +fm_test_tmproot TMP_ROOT fm-cd-pretool-check # A primary-shaped checkout: plain (non-worktree) git repo, AGENTS.md, bin/ with # the transport plus both policy files (fm-cd-command-policy.mjs imports the @@ -143,6 +143,7 @@ matrix_case A35 allow 'command -pv cd' matrix_case A36 allow 'command -vp cd' MATRIX_TMP=$(mktemp -d "${TMPDIR:-/tmp}/fm-cd-policy-matrix.XXXXXX") +fm_test_install_cleanup_traps FM_TEST_CLEANUP_DIRS+=("$MATRIX_TMP") run_matrix_entry() { diff --git a/tests/fm-composer-ghost.test.sh b/tests/fm-composer-ghost.test.sh index 3be65186b..ffaa7e5c2 100755 --- a/tests/fm-composer-ghost.test.sh +++ b/tests/fm-composer-ghost.test.sh @@ -26,7 +26,7 @@ PEEK="$ROOT/bin/fm-peek.sh" # shellcheck source=bin/fm-tmux-lib.sh . "$LIB" -TMP_ROOT=$(fm_test_tmproot fm-ghost-tests) +fm_test_tmproot TMP_ROOT fm-ghost-tests # ESC byte for building styled fixtures and asserting escape-free output. ESC=$(printf '\033') diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index e5ffd09a8..faec7f9ac 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -33,7 +33,8 @@ set -u . "$ROOT/bin/fm-classify-lib.sh" CREW_STATE="$ROOT/bin/fm-crew-state.sh" -TMP_ROOT=$(fm_test_tmproot fm-crew-state) +fm_test_tmproot TMP_ROOT fm-crew-state + fm_git_identity fmtest fmtest@example.invalid # A real git repo checked out on , so the helper's branch attribution diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index c6710a63d..54eeed9ba 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -21,7 +21,7 @@ if [ -z "${FM_TEST_DAEMON_SOURCED:-}" ]; then . "$DAEMON" fi -TMP_ROOT=$(fm_test_tmproot fm-daemon-tests) +fm_test_tmproot TMP_ROOT fm-daemon-tests test_afk_start_refuses_when_flag_cannot_be_written() { local dir state out status diff --git a/tests/fm-decision-hold-lifecycle.test.sh b/tests/fm-decision-hold-lifecycle.test.sh index 0ef84c4a6..f279682ef 100755 --- a/tests/fm-decision-hold-lifecycle.test.sh +++ b/tests/fm-decision-hold-lifecycle.test.sh @@ -9,7 +9,8 @@ set -u TEARDOWN="$ROOT/bin/fm-teardown.sh" BEARINGS="$ROOT/bin/fm-bearings-snapshot.sh" -TMP_ROOT=$(fm_test_tmproot fm-decision-hold) +fm_test_tmproot TMP_ROOT fm-decision-hold + TASKS_AXI_BIN=$(command -v tasks-axi || true) command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } diff --git a/tests/fm-dispatch-select.test.sh b/tests/fm-dispatch-select.test.sh index a9ae5b7bf..ccd41d8e6 100755 --- a/tests/fm-dispatch-select.test.sh +++ b/tests/fm-dispatch-select.test.sh @@ -6,7 +6,8 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} -TMP_ROOT=$(fm_test_tmproot fm-dispatch-select-tests) +fm_test_tmproot TMP_ROOT fm-dispatch-select-tests + mkdir -p "$TMP_ROOT" write_quota() { diff --git a/tests/fm-ensure-agents-md.test.sh b/tests/fm-ensure-agents-md.test.sh index 2b74b03e1..b927c1b2c 100755 --- a/tests/fm-ensure-agents-md.test.sh +++ b/tests/fm-ensure-agents-md.test.sh @@ -5,7 +5,7 @@ set -u # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" -TMP_ROOT=$(fm_test_tmproot fm-ensure-agents-md) +fm_test_tmproot TMP_ROOT fm-ensure-agents-md test_created_agents_md_includes_self_governance() { local repo agents diff --git a/tests/fm-firstmate-update-check.test.sh b/tests/fm-firstmate-update-check.test.sh index 2f2806315..44d2e37e1 100755 --- a/tests/fm-firstmate-update-check.test.sh +++ b/tests/fm-firstmate-update-check.test.sh @@ -5,7 +5,7 @@ set -u # shellcheck source=tests/lib.sh disable=SC1091 . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" -TMP_ROOT=$(fm_test_tmproot fm-firstmate-update-check-tests) +fm_test_tmproot TMP_ROOT fm-firstmate-update-check-tests commit_file() { local repo=$1 path=$2 content=$3 diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 80c951135..460983ca2 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -8,7 +8,7 @@ set -u SNAPSHOT="$ROOT/bin/fm-fleet-snapshot.sh" VIEW="$ROOT/bin/fm-fleet-view.sh" -TMP_ROOT=$(fm_test_tmproot fm-fleet-snapshot) +fm_test_tmproot TMP_ROOT fm-fleet-snapshot command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } diff --git a/tests/fm-fleet-sync.test.sh b/tests/fm-fleet-sync.test.sh index c9d711ad1..f39c29f90 100755 --- a/tests/fm-fleet-sync.test.sh +++ b/tests/fm-fleet-sync.test.sh @@ -28,7 +28,8 @@ set -u fm_git_identity fmtest fmtest@example.invalid -TMP_ROOT=$(fm_test_tmproot fm-fleet-sync-tests) +fm_test_tmproot TMP_ROOT fm-fleet-sync-tests + HOME_N=0 # --- fixtures --------------------------------------------------------------- diff --git a/tests/fm-fork-sync-check.test.sh b/tests/fm-fork-sync-check.test.sh index 1b7f7d3c8..421b5977c 100755 --- a/tests/fm-fork-sync-check.test.sh +++ b/tests/fm-fork-sync-check.test.sh @@ -5,7 +5,7 @@ set -u # shellcheck source=tests/lib.sh disable=SC1091 . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" -TMP_ROOT=$(fm_test_tmproot fm-fork-sync-check-tests) +fm_test_tmproot TMP_ROOT fm-fork-sync-check-tests commit_file() { local repo=$1 path=$2 content=$3 diff --git a/tests/fm-gate-refuse.test.sh b/tests/fm-gate-refuse.test.sh index 5f13039c1..a61765d40 100755 --- a/tests/fm-gate-refuse.test.sh +++ b/tests/fm-gate-refuse.test.sh @@ -35,7 +35,8 @@ SPAWN="$ROOT/bin/fm-spawn.sh" SEND="$ROOT/bin/fm-send.sh" TEARDOWN="$ROOT/bin/fm-teardown.sh" -TMP=$(fm_test_tmproot fm-gate-refuse) +fm_test_tmproot TMP fm-gate-refuse + fm_git_identity fmtest fmtest@example.invalid # The env marker's exact stderr fragment (the primary signal). diff --git a/tests/fm-grok-harness.test.sh b/tests/fm-grok-harness.test.sh index edb5001b9..4376cdebe 100755 --- a/tests/fm-grok-harness.test.sh +++ b/tests/fm-grok-harness.test.sh @@ -7,7 +7,7 @@ set -u SPAWN="$ROOT/bin/fm-spawn.sh" TEARDOWN="$ROOT/bin/fm-teardown.sh" -TMP_ROOT=$(fm_test_tmproot fm-grok-harness) +fm_test_tmproot TMP_ROOT fm-grok-harness make_spawn_fakebin() { local dir=$1 fakebin diff --git a/tests/fm-guard-stale-banner.test.sh b/tests/fm-guard-stale-banner.test.sh index 862eb9c70..6d39d5d10 100755 --- a/tests/fm-guard-stale-banner.test.sh +++ b/tests/fm-guard-stale-banner.test.sh @@ -10,7 +10,7 @@ set -u # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" -TMP_ROOT=$(fm_test_tmproot fm-guard-stale-banner) +fm_test_tmproot TMP_ROOT fm-guard-stale-banner make_guard_case() { local name=$1 dir home root diff --git a/tests/fm-herdr-lab.test.sh b/tests/fm-herdr-lab.test.sh index c004226aa..692620fd3 100755 --- a/tests/fm-herdr-lab.test.sh +++ b/tests/fm-herdr-lab.test.sh @@ -5,7 +5,8 @@ set -u # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" -TMP_ROOT=$(fm_test_tmproot fm-herdr-lab) +fm_test_tmproot TMP_ROOT fm-herdr-lab + FAKEBIN=$(fm_fakebin "$TMP_ROOT") FAKE_STATE="$TMP_ROOT/herdr-state" FAKE_LOG="$TMP_ROOT/herdr.log" diff --git a/tests/fm-lint.test.sh b/tests/fm-lint.test.sh index 09dcb2ba5..7f85fff17 100755 --- a/tests/fm-lint.test.sh +++ b/tests/fm-lint.test.sh @@ -86,7 +86,7 @@ test_rejects_wrong_shellcheck_version() { # Version-independent: a fake shellcheck reporting a different version must be # refused before any lint, proving local and CI cannot silently diverge. local tmp fakebin out rc - tmp=$(fm_test_tmproot fm-lint-ver) + fm_test_tmproot tmp fm-lint-ver fakebin=$(fm_fakebin "$tmp") cat > "$fakebin/shellcheck" <<'SH' #!/usr/bin/env bash @@ -118,7 +118,7 @@ test_catches_a_real_lint_defect() { # warning present at default severity (and is itself one of the recurring # classes that slipped through, PR 474). local tmp bad out rc - tmp=$(fm_test_tmproot fm-lint-bad) + fm_test_tmproot tmp fm-lint-bad mkdir -p "$tmp" bad="$tmp/bad.sh" cat > "$bad" <<'SH' @@ -142,7 +142,7 @@ test_ignores_ambient_shellcheck_opts() { return fi local tmp bad out rc - tmp=$(fm_test_tmproot fm-lint-opts) + fm_test_tmproot tmp fm-lint-opts mkdir -p "$tmp" bad="$tmp/bad.sh" cat > "$bad" <<'SH' @@ -166,7 +166,7 @@ test_clean_fixture_passes() { return fi local tmp good rc - tmp=$(fm_test_tmproot fm-lint-good) + fm_test_tmproot tmp fm-lint-good mkdir -p "$tmp" good="$tmp/good.sh" cat > "$good" <<'SH' diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 84dd9b5f9..c622632f0 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -5,7 +5,8 @@ set -u # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" -TMP_ROOT=$(fm_test_tmproot fm-pi-watch-extension) +fm_test_tmproot TMP_ROOT fm-pi-watch-extension + EXT="$ROOT/.pi/extensions/fm-primary-pi-watch.ts" # Node 24 warns when these test-only dynamic imports load tracked ESM plugins # from a clean checkout with no tracked .opencode/package.json. The warning is diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh index 9d4154e50..3d40192bd 100755 --- a/tests/fm-pr-check-security.test.sh +++ b/tests/fm-pr-check-security.test.sh @@ -19,7 +19,8 @@ POLL="$ROOT/bin/fm-pr-poll.sh" WATCH="$ROOT/bin/fm-watch.sh" TEARDOWN="$ROOT/bin/fm-teardown.sh" REGISTER="$ROOT/bin/fm-check-register.sh" -TMP_ROOT=$(fm_test_tmproot fm-pr-check-security) +fm_test_tmproot TMP_ROOT fm-pr-check-security + BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} REAL_CP=$(command -v cp) REAL_MV=$(command -v mv) diff --git a/tests/fm-pr-merge.test.sh b/tests/fm-pr-merge.test.sh index a064b6919..4f13dfedd 100755 --- a/tests/fm-pr-merge.test.sh +++ b/tests/fm-pr-merge.test.sh @@ -21,7 +21,7 @@ set -u fm_git_identity fmtest fmtest@example.invalid PR_MERGE="$ROOT/bin/fm-pr-merge.sh" -TMP_ROOT=$(fm_test_tmproot fm-pr-merge-tests) +fm_test_tmproot TMP_ROOT fm-pr-merge-tests # Build a fresh sandbox for one test case: a state dir with a task meta and a # fakebin with a gh-axi mock that records how it was invoked. Echoes the case dir. diff --git a/tests/fm-review-diff.test.sh b/tests/fm-review-diff.test.sh index e340702ae..becb4dc6a 100755 --- a/tests/fm-review-diff.test.sh +++ b/tests/fm-review-diff.test.sh @@ -15,7 +15,7 @@ set -u fm_git_identity fmtest fmtest@example.invalid REVIEW_DIFF="$ROOT/bin/fm-review-diff.sh" -TMP_ROOT=$(fm_test_tmproot fm-review-diff-tests) +fm_test_tmproot TMP_ROOT fm-review-diff-tests make_case() { local name=$1 case_dir diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index 2e4ca805e..92877efe7 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -39,7 +39,8 @@ set -u BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} fm_git_identity fmtest fmtest@example.com -TMP_ROOT=$(fm_test_tmproot fm-secondmate-harness) +fm_test_tmproot TMP_ROOT fm-secondmate-harness + CODEX_PROFILE_FLAGS='-c '\''sandbox_mode="workspace-write"'\'' -c '\''approval_policy="on-request"'\'' -c '\''approvals_reviewer="auto_review"'\''' export FM_BACKEND=tmux diff --git a/tests/fm-secondmate-lifecycle-e2e.test.sh b/tests/fm-secondmate-lifecycle-e2e.test.sh index fd71fdb35..614ae2016 100755 --- a/tests/fm-secondmate-lifecycle-e2e.test.sh +++ b/tests/fm-secondmate-lifecycle-e2e.test.sh @@ -27,7 +27,8 @@ set -u # shellcheck source=tests/secondmate-helpers.sh disable=SC1091 . "$(dirname "${BASH_SOURCE[0]}")/secondmate-helpers.sh" -TMP_ROOT=$(fm_test_tmproot fm-secondmate-lifecycle) +fm_test_tmproot TMP_ROOT fm-secondmate-lifecycle + export FM_BACKEND=tmux HOME_DIR="$TMP_ROOT/main home" diff --git a/tests/fm-secondmate-liveness.test.sh b/tests/fm-secondmate-liveness.test.sh index cb8b19d18..9d56911be 100755 --- a/tests/fm-secondmate-liveness.test.sh +++ b/tests/fm-secondmate-liveness.test.sh @@ -42,7 +42,7 @@ set -u BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} fm_git_identity fmtest fmtest@example.com -TMP_ROOT=$(fm_test_tmproot fm-secondmate-liveness) +fm_test_tmproot TMP_ROOT fm-secondmate-liveness # --- unit level: fm_backend_tmux_agent_alive -------------------------------- diff --git a/tests/fm-secondmate-safety.test.sh b/tests/fm-secondmate-safety.test.sh index bf6551605..775933db4 100755 --- a/tests/fm-secondmate-safety.test.sh +++ b/tests/fm-secondmate-safety.test.sh @@ -10,7 +10,8 @@ set -u # shellcheck source=tests/secondmate-helpers.sh disable=SC1091 . "$(dirname "${BASH_SOURCE[0]}")/secondmate-helpers.sh" -TMP_ROOT=$(fm_test_tmproot fm-secondmate-safety) +fm_test_tmproot TMP_ROOT fm-secondmate-safety + export FM_BACKEND=tmux file_mode() { diff --git a/tests/fm-secondmate-sync.test.sh b/tests/fm-secondmate-sync.test.sh index 6154498b7..b924c28f3 100755 --- a/tests/fm-secondmate-sync.test.sh +++ b/tests/fm-secondmate-sync.test.sh @@ -33,7 +33,8 @@ BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} # Deterministic, isolated git identity for fixture commits. fm_git_identity fmtest fmtest@example.com -TMP_ROOT=$(fm_test_tmproot fm-secondmate-sync) +fm_test_tmproot TMP_ROOT fm-secondmate-sync + export FM_BACKEND=tmux # --- world builders -------------------------------------------------------- diff --git a/tests/fm-self-drift.test.sh b/tests/fm-self-drift.test.sh index 8fd682c5e..c13709181 100755 --- a/tests/fm-self-drift.test.sh +++ b/tests/fm-self-drift.test.sh @@ -9,7 +9,8 @@ set -u # shellcheck source=tests/lib.sh disable=SC1091 . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" -TMP_ROOT=$(fm_test_tmproot fm-self-drift) +fm_test_tmproot TMP_ROOT fm-self-drift + fm_git_identity make_case() { diff --git a/tests/fm-send-popup-settle.test.sh b/tests/fm-send-popup-settle.test.sh index 685ce627a..7423aeed2 100755 --- a/tests/fm-send-popup-settle.test.sh +++ b/tests/fm-send-popup-settle.test.sh @@ -36,7 +36,7 @@ set -u SEND="$ROOT/bin/fm-send.sh" -TMP_ROOT=$(fm_test_tmproot fm-send-popup-settle) +fm_test_tmproot TMP_ROOT fm-send-popup-settle # Same stub shape as fm-send-settle.test.sh: a fake tmux that drives the submit # path to a clean "empty" verdict on the first Enter, and a fake sleep that records diff --git a/tests/fm-send-secondmate-marker.test.sh b/tests/fm-send-secondmate-marker.test.sh index 2e60d9375..595c343bb 100755 --- a/tests/fm-send-secondmate-marker.test.sh +++ b/tests/fm-send-secondmate-marker.test.sh @@ -23,7 +23,7 @@ set -u SEND="$ROOT/bin/fm-send.sh" -TMP_ROOT=$(fm_test_tmproot fm-send-marker) +fm_test_tmproot TMP_ROOT fm-send-marker # A fake tmux that (a) records the literal text of every `send-keys -l` to # FM_SEND_LOG and (b) lets fm-send's submit path reach a clean "empty" verdict. diff --git a/tests/fm-send-settle.test.sh b/tests/fm-send-settle.test.sh index 93a9c351b..0ef7f7ee7 100755 --- a/tests/fm-send-settle.test.sh +++ b/tests/fm-send-settle.test.sh @@ -19,7 +19,7 @@ set -u SEND="$ROOT/bin/fm-send.sh" -TMP_ROOT=$(fm_test_tmproot fm-send-settle) +fm_test_tmproot TMP_ROOT fm-send-settle # A fake tmux that lets fm-send's submit path reach a clean "empty" verdict, plus a # fake sleep that records every requested duration (one per line) instead of diff --git a/tests/fm-send-strict.test.sh b/tests/fm-send-strict.test.sh index 21305d4a2..d54cb6926 100755 --- a/tests/fm-send-strict.test.sh +++ b/tests/fm-send-strict.test.sh @@ -11,7 +11,7 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" SEND="$ROOT/bin/fm-send.sh" -TMP_ROOT=$(fm_test_tmproot fm-send-strict) +fm_test_tmproot TMP_ROOT fm-send-strict make_stubs() { # -> echoes fakebin dir local dir=$1 fb="$1/fakebin" diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 3a18882a6..fa2b3038c 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -27,7 +27,8 @@ set -u SESSION_START="$ROOT/bin/fm-session-start.sh" BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} -TMP_ROOT=$(fm_test_tmproot fm-session-start-tests) +fm_test_tmproot TMP_ROOT fm-session-start-tests + fm_git_identity fmtest fmtest@example.invalid # --- world builders ---------------------------------------------------------- diff --git a/tests/fm-sessionstart-nudge.test.sh b/tests/fm-sessionstart-nudge.test.sh index 25a304d14..7c9bd7988 100755 --- a/tests/fm-sessionstart-nudge.test.sh +++ b/tests/fm-sessionstart-nudge.test.sh @@ -5,7 +5,8 @@ set -u # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" -TMP_ROOT=$(fm_test_tmproot fm-sessionstart-nudge) +fm_test_tmproot TMP_ROOT fm-sessionstart-nudge + NUDGE="$ROOT/bin/fm-sessionstart-nudge.sh" NUDGE_LINE="Run \`bin/fm-session-start.sh\` now, exactly once, before executing any other instructions." fm_git_identity fmtest fmtest@example.invalid diff --git a/tests/fm-shared-captain-inheritance.test.sh b/tests/fm-shared-captain-inheritance.test.sh index cb0747c57..fb1ee0c50 100755 --- a/tests/fm-shared-captain-inheritance.test.sh +++ b/tests/fm-shared-captain-inheritance.test.sh @@ -12,7 +12,7 @@ set -u . "$ROOT/bin/fm-config-inherit-lib.sh" BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} -TMP_ROOT=$(fm_test_tmproot fm-shared-captain) +fm_test_tmproot TMP_ROOT fm-shared-captain fm_git_identity fmtest fmtest@example.invalid diff --git a/tests/fm-spawn-batch.test.sh b/tests/fm-spawn-batch.test.sh index 4f6f10c5e..0bd286f1e 100755 --- a/tests/fm-spawn-batch.test.sh +++ b/tests/fm-spawn-batch.test.sh @@ -13,7 +13,8 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" SPAWN="$ROOT/bin/fm-spawn.sh" -TMP_ROOT=$(fm_test_tmproot fm-spawn-batch) +fm_test_tmproot TMP_ROOT fm-spawn-batch + export FM_BACKEND=tmux # Clear ambient firstmate overrides so the behavior test owns its environment. diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 770f0d68e..605cd31d6 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -11,7 +11,8 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" SPAWN="$ROOT/bin/fm-spawn.sh" -TMP_ROOT=$(fm_test_tmproot fm-spawn-dispatch-profile) +fm_test_tmproot TMP_ROOT fm-spawn-dispatch-profile + CODEX_PROFILE_FLAGS='-c '\''sandbox_mode="workspace-write"'\'' -c '\''approval_policy="on-request"'\'' -c '\''approvals_reviewer="auto_review"'\''' make_spawn_fakebin() { diff --git a/tests/fm-supervision-events.test.sh b/tests/fm-supervision-events.test.sh index 221b32fbe..39982dc86 100755 --- a/tests/fm-supervision-events.test.sh +++ b/tests/fm-supervision-events.test.sh @@ -11,7 +11,8 @@ set -u # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" -TMP=$(fm_test_tmproot fm-supervision-events) +fm_test_tmproot TMP fm-supervision-events + STATE_DIR="$TMP/state" mkdir -p "$STATE_DIR" diff --git a/tests/fm-supervision-instructions.test.sh b/tests/fm-supervision-instructions.test.sh index c1072063a..0b9a7e6d1 100755 --- a/tests/fm-supervision-instructions.test.sh +++ b/tests/fm-supervision-instructions.test.sh @@ -5,7 +5,8 @@ set -u # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" -TMP_ROOT=$(fm_test_tmproot fm-supervision-instructions) +fm_test_tmproot TMP_ROOT fm-supervision-instructions + RENDER="$ROOT/bin/fm-supervision-instructions.sh" test_selected_harness_block_only() { diff --git a/tests/fm-tangle-guard.test.sh b/tests/fm-tangle-guard.test.sh index a6f24eaa5..8273ecd7b 100755 --- a/tests/fm-tangle-guard.test.sh +++ b/tests/fm-tangle-guard.test.sh @@ -21,7 +21,8 @@ set -u # shellcheck source=bin/fm-tangle-lib.sh . "$ROOT/bin/fm-tangle-lib.sh" -TMP_ROOT=$(fm_test_tmproot fm-tangle-guard) +fm_test_tmproot TMP_ROOT fm-tangle-guard + fm_git_identity fmtest fmtest@example.invalid # A fresh git repo on `main` with one commit. Echoes its path. diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 95b7c006c..bab5f4851 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -57,7 +57,8 @@ fm_git_identity fmtest fmtest@example.invalid TEARDOWN="$ROOT/bin/fm-teardown.sh" PR_CHECK="$ROOT/bin/fm-pr-check.sh" -TMP_ROOT=$(fm_test_tmproot fm-teardown-tests) +fm_test_tmproot TMP_ROOT fm-teardown-tests + REAL_GIT_FOR_TEST=$(command -v git) export REAL_GIT_FOR_TEST diff --git a/tests/fm-test-lib.test.sh b/tests/fm-test-lib.test.sh new file mode 100755 index 000000000..8585e326d --- /dev/null +++ b/tests/fm-test-lib.test.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# tests/fm-test-lib.test.sh - shared test-helper cleanup behavior. +# The shared temp-root helper must register cleanup in the caller shell and turn +# catchable interrupts into explicit exits through the current EXIT trap. +# SIGKILL is intentionally not covered because the kernel does not allow Bash to +# trap it; timeout wrappers must allow their initial HUP, INT, or TERM to run. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +fm_test_tmproot TMP_ROOT fm-test-lib + +PROBE="$TMP_ROOT/cleanup-probe.sh" +cat > "$PROBE" <<'PROBE' +#!/usr/bin/env bash +set -u +. "$1" +fm_test_tmproot scratch fm-test-lib-child + +printf '%s\n' "$scratch" > "$2" +case "$3" in + exit) exit 0 ;; + term-custom-exit) + marker=$4 + trap 'printf "custom-exit-ran\n" > "$marker"; fm_test_cleanup' EXIT + while :; do sleep 1; done + ;; + *) exit 2 ;; +esac +PROBE +chmod +x "$PROBE" + +wait_for_path() { + local file=$1 pid=$2 i=0 + while [ "$i" -lt 50 ] && [ ! -s "$file" ]; do + kill -0 "$pid" 2>/dev/null || break + sleep 0.02 + i=$((i + 1)) + done + [ -s "$file" ] +} + +test_normal_exit_cleans_registered_root() { + local path_file="$TMP_ROOT/normal.path" scratch + bash "$PROBE" "$ROOT/tests/lib.sh" "$path_file" exit || fail "normal cleanup probe failed" + scratch=$(cat "$path_file") + [ ! -e "$scratch" ] || { + rm -rf "$scratch" + fail "caller-shell temp root survived normal exit" + } + pass "caller-shell temp-root registration cleans on normal exit" +} + +test_term_runs_custom_exit_and_cleans_registered_root() { + local path_file="$TMP_ROOT/term.path" marker="$TMP_ROOT/custom-exit" pid rc scratch + bash "$PROBE" "$ROOT/tests/lib.sh" "$path_file" term-custom-exit "$marker" & + pid=$! + wait_for_path "$path_file" "$pid" || { + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fail "TERM cleanup probe did not publish its scratch path" + } + kill -TERM "$pid" || fail "could not interrupt cleanup probe" + rc=0 + wait "$pid" || rc=$? + [ "$rc" -eq 143 ] || fail "TERM cleanup probe returned $rc instead of 143" + scratch=$(cat "$path_file") + [ -f "$marker" ] || fail "test-owned EXIT trap did not run after TERM" + [ ! -e "$scratch" ] || { + rm -rf "$scratch" + fail "registered temp root survived TERM" + } + pass "TERM preserves a test-owned EXIT trap and cleans its registered root" +} + +test_normal_exit_cleans_registered_root +test_term_runs_custom_exit_and_cleans_registered_root diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 644605047..8c09e1f64 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -16,7 +16,8 @@ set -u # shellcheck source=bin/fm-supervision-lib.sh . "$ROOT/bin/fm-supervision-lib.sh" -TMP_ROOT=$(fm_test_tmproot fm-turnend-guard) +fm_test_tmproot TMP_ROOT fm-turnend-guard + fm_git_identity fmtest fmtest@example.invalid REQUIRED_REASON='resume supervision with bin/fm-watch-arm.sh as its own Claude Code background task' diff --git a/tests/fm-update.test.sh b/tests/fm-update.test.sh index 14628e303..3909de8fe 100755 --- a/tests/fm-update.test.sh +++ b/tests/fm-update.test.sh @@ -27,7 +27,7 @@ UPDATE="$ROOT/bin/fm-update.sh" # Deterministic, isolated git identity for fixture commits. fm_git_identity fmtest fmtest@example.com -TMP_ROOT=$(fm_test_tmproot fm-update-tests) +fm_test_tmproot TMP_ROOT fm-update-tests # Build a fresh world: a bare origin seeded with one commit, a firstmate repo # clone checked out on main, and a home dir with state/ and data/. Echoes the diff --git a/tests/fm-wake-daemon-lifecycle-e2e.test.sh b/tests/fm-wake-daemon-lifecycle-e2e.test.sh index e027dca96..99bffc5c9 100755 --- a/tests/fm-wake-daemon-lifecycle-e2e.test.sh +++ b/tests/fm-wake-daemon-lifecycle-e2e.test.sh @@ -31,7 +31,7 @@ if [ -z "${FM_TEST_DAEMON_SOURCED:-}" ]; then . "$DAEMON" fi -TMP_ROOT=$(fm_test_tmproot fm-wake-daemon-e2e) +fm_test_tmproot TMP_ROOT fm-wake-daemon-e2e # Run the daemon-managed watcher once: under the supervise-daemon (away mode) the # watcher is one-shot - it exits with a single reason line on EVERY wake and the diff --git a/tests/fm-wake-queue.test.sh b/tests/fm-wake-queue.test.sh index 268746c6e..538fa5afa 100755 --- a/tests/fm-wake-queue.test.sh +++ b/tests/fm-wake-queue.test.sh @@ -14,8 +14,34 @@ set -u WATCH="$ROOT/bin/fm-watch.sh" DRAIN="$ROOT/bin/fm-wake-drain.sh" -TMP_ROOT=$(fm_test_tmproot fm-wake-tests) +fm_test_tmproot TMP_ROOT fm-wake-tests +test_append_returns_on_lock_filesystem_failure() { + local dir state fakebin out err pid rc + dir=$(make_case append-lock-filesystem-failure) + state="$dir/state" + fakebin="$dir/fakebin" + out="$dir/append.out" + err="$dir/append.err" + cat > "$fakebin/ln" <<'SH' +#!/usr/bin/env bash +exit 74 +SH + chmod +x "$fakebin/ln" + + PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$state" bash -c ' + . "$1" + fm_wake_append signal task "signal: task" + ' _ "$ROOT/bin/fm-wake-lib.sh" > "$out" 2> "$err" & + pid=$! + rc=0 + wait_for_exit "$pid" 20 || rc=$? + [ "$rc" -eq 2 ] || fail "wake append lock failure returned $rc instead of 2" + grep -F 'could not publish lock' "$err" >/dev/null \ + || fail "wake append did not report lock publication failure: $(cat "$err")" + [ ! -s "$state/.wake-queue" ] || fail "wake append wrote a queue record without its lock" + pass "wake append returns promptly and writes nothing when lock publication fails" +} test_concurrent_append_and_drain() { local dir state out1 out2 all pids i pid count unique malformed @@ -234,6 +260,7 @@ test_drain_asserts_watcher_liveness() { pass "drain asserts watcher liveness: warns on a lapse, stays silent right after a fire" } +test_append_returns_on_lock_filesystem_failure test_concurrent_append_and_drain test_signal_catchup_without_running_watcher test_stale_enqueue_before_suppressor diff --git a/tests/fm-watch-bridge-inbox.test.sh b/tests/fm-watch-bridge-inbox.test.sh index f8ed76eb0..a95329325 100755 --- a/tests/fm-watch-bridge-inbox.test.sh +++ b/tests/fm-watch-bridge-inbox.test.sh @@ -6,7 +6,7 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" WATCH="$ROOT/bin/fm-watch.sh" -TMP_ROOT=$(fm_test_tmproot fm-watch-bridge-inbox) +fm_test_tmproot TMP_ROOT fm-watch-bridge-inbox make_home() { local name=$1 home bridge origin diff --git a/tests/fm-watch-checkpoint.test.sh b/tests/fm-watch-checkpoint.test.sh index 9312c6ddc..8663a3a9f 100755 --- a/tests/fm-watch-checkpoint.test.sh +++ b/tests/fm-watch-checkpoint.test.sh @@ -6,7 +6,7 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" CHECKPOINT="$ROOT/bin/fm-watch-checkpoint.sh" -TMP_ROOT=$(fm_test_tmproot fm-watch-checkpoint) +fm_test_tmproot TMP_ROOT fm-watch-checkpoint make_home() { local name=$1 home diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 4cbc4de4f..c78ceab26 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -25,7 +25,7 @@ set -u WATCH="$ROOT/bin/fm-watch.sh" DRAIN="$ROOT/bin/fm-wake-drain.sh" -TMP_ROOT=$(fm_test_tmproot fm-watch-triage-tests) +fm_test_tmproot TMP_ROOT fm-watch-triage-tests # Common watcher knobs: tight poll/grace, no check or heartbeat cadence unless a # test overrides them, so a test only exercises the path it targets. FM_CREW_STATE_BIN diff --git a/tests/fm-watcher-lock.test.sh b/tests/fm-watcher-lock.test.sh index 36e1f8dd4..8261cca6b 100755 --- a/tests/fm-watcher-lock.test.sh +++ b/tests/fm-watcher-lock.test.sh @@ -13,7 +13,7 @@ WATCH_ARM="$ROOT/bin/fm-watch-arm.sh" DRAIN="$ROOT/bin/fm-wake-drain.sh" LIB="$ROOT/bin/fm-wake-lib.sh" -TMP_ROOT=$(fm_test_tmproot fm-watcher-lock-tests) +fm_test_tmproot TMP_ROOT fm-watcher-lock-tests mark_pr_check_migration_complete() { local state=$1 @@ -168,6 +168,55 @@ test_guard_warnings() { pass "guard banner leads when down with pending wakes (re-arm-after-drain) and stays silent when fresh" } +test_lock_create_filesystem_failure_returns_without_steal_recursion() { + local dir state fakebin lockdir out err pid rc steal_paths + dir=$(make_case lock-create-filesystem-failure) + state="$dir/state" + fakebin="$dir/fakebin" + lockdir="$state/.contend.lock" + out="$dir/acquire.out" + err="$dir/acquire.err" + cat > "$fakebin/ln" <<'SH' +#!/usr/bin/env bash +exit 74 +SH + chmod +x "$fakebin/ln" + + PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$state" bash -c ' + . "$1" + fm_lock_try_acquire "$2" + ' _ "$LIB" "$lockdir" > "$out" 2> "$err" & + pid=$! + rc=0 + wait_for_exit "$pid" 20 || rc=$? + [ "$rc" -eq 2 ] || fail "failed lock publication returned $rc instead of filesystem error 2" + grep -F "could not publish lock $lockdir" "$err" >/dev/null \ + || fail "failed lock publication did not report its filesystem error: $(cat "$err")" + steal_paths=$(find "$state" -name '*.steal*' -print) + [ -z "$steal_paths" ] || fail "failed lock publication created steal-chain paths: $steal_paths" + pass "lock publication failure returns promptly without entering steal recursion" +} + +test_lock_steal_recursion_has_a_hard_depth_bound() { + local dir state lockdir out err rc + dir=$(make_case lock-steal-depth-bound) + state="$dir/state" + lockdir="$state/.contend.lock" + out="$dir/acquire.out" + err="$dir/acquire.err" + rc=0 + FM_LOCK_STEAL_MAX_DEPTH=3 FM_STATE_OVERRIDE="$state" bash -c ' + . "$1" + fm_lock_try_create() { return 1; } + fm_lock_mid_acquire_is_fresh() { return 1; } + fm_lock_try_acquire "$2" + ' _ "$LIB" "$lockdir" > "$out" 2> "$err" || rc=$? + [ "$rc" -eq 2 ] || fail "steal recursion depth bound returned $rc instead of 2" + grep -F 'steal depth exceeded 3' "$err" >/dev/null \ + || fail "steal recursion did not report its configured depth bound: $(cat "$err")" + pass "lock steal recursion stops at its configured hard depth bound" +} + test_lock_single_winner_under_concurrency() { local dir state lockdir marker i pids pid wins dir=$(make_case lock-concurrency) @@ -731,6 +780,8 @@ test_pid_identity_is_locale_invariant test_stale_watch_lock_reclaimed test_live_stale_watch_lock_is_actionable test_guard_warnings +test_lock_create_filesystem_failure_returns_without_steal_recursion +test_lock_steal_recursion_has_a_hard_depth_bound test_lock_single_winner_under_concurrency test_lock_steals_dead_pid_lock test_lock_stale_steal_single_winner_under_concurrency diff --git a/tests/fm-x-mode.test.sh b/tests/fm-x-mode.test.sh index da2800034..c8b954c55 100755 --- a/tests/fm-x-mode.test.sh +++ b/tests/fm-x-mode.test.sh @@ -19,7 +19,7 @@ BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} # not include. Prepended after the fakebin so the fake curl still wins. JQ_DIR=$(command -v jq 2>/dev/null) && JQ_DIR=$(dirname "$JQ_DIR") || JQ_DIR= [ -n "$JQ_DIR" ] && BASE_PATH="$JQ_DIR:$BASE_PATH" -TMP_ROOT=$(fm_test_tmproot fm-x-mode-tests) +fm_test_tmproot TMP_ROOT fm-x-mode-tests # A fakebin `curl` that mimics the relay: it reads its behavior from env # (FAKE_POLL_CODE/FAKE_POLL_BODY/FAKE_ANSWER_CODE, and diff --git a/tests/lib.sh b/tests/lib.sh index 2f68bf14f..229cbc14a 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -57,10 +57,18 @@ pass() { # --- self-cleaning temp root ------------------------------------------------ # -# fm_test_tmproot echoes a fresh temp dir and registers it for removal -# on EXIT. The first call installs the cleanup trap. A test file that needs -# extra teardown (e.g. killing a daemon) should define its own EXIT trap and -# call fm_test_cleanup from inside it so registered dirs are still removed. +# fm_test_tmproot [prefix] assigns a fresh temp dir to output-var +# and registers it for removal on EXIT. Assignment must happen in the calling +# shell: command substitution runs the function in a subshell and loses both +# the cleanup registration and its traps. The first call installs the cleanup +# traps. A test file that needs extra teardown (e.g. killing a daemon) should +# define its own EXIT trap and call fm_test_cleanup from inside it so registered +# dirs are still removed. +# +# HUP, INT, and TERM are converted to explicit exits so the current EXIT trap +# runs on those interrupt paths. SIGKILL cannot be trapped by Bash, so a wrapper +# that escalates to SIGKILL can only be cleaned up if its earlier signal reaches +# this shell and is not deliberately ignored. FM_TEST_CLEANUP_DIRS=() @@ -71,14 +79,35 @@ fm_test_cleanup() { done } -fm_test_tmproot() { - local prefix=${1:-fm-test} root - root=$(mktemp -d "${TMPDIR:-/tmp}/${prefix}.XXXXXX") +# shellcheck disable=SC2317,SC2329 # Invoked by the signal traps below. +fm_test_signal_exit() { + local status=$1 + trap - HUP INT TERM + exit "$status" +} + +fm_test_install_cleanup_traps() { if [ "${#FM_TEST_CLEANUP_DIRS[@]}" -eq 0 ]; then trap fm_test_cleanup EXIT + trap 'fm_test_signal_exit 129' HUP + trap 'fm_test_signal_exit 130' INT + trap 'fm_test_signal_exit 143' TERM fi +} + +fm_test_tmproot() { + local output_var=${1:-} prefix=${2:-fm-test} root + case "$output_var" in + ''|[0-9]*|*[!a-zA-Z0-9_]*) + printf 'fm_test_tmproot: first argument must be an output variable name\n' >&2 + return 2 + ;; + esac + root=$(mktemp -d "${TMPDIR:-/tmp}/${prefix}.XXXXXX") \ + || fail "could not create test temp root under ${TMPDIR:-/tmp}" + fm_test_install_cleanup_traps FM_TEST_CLEANUP_DIRS+=("$root") - printf '%s\n' "$root" + printf -v "$output_var" '%s' "$root" } # --- fakebin / PATH shims --------------------------------------------------- diff --git a/tests/wake-helpers.sh b/tests/wake-helpers.sh index 94879072a..1138b0de7 100644 --- a/tests/wake-helpers.sh +++ b/tests/wake-helpers.sh @@ -16,7 +16,7 @@ # direct fm-guard.sh tests use. A per-call FM_ROOT_OVERRIDE still wins where a # suite sets its own (e.g. the watcher-lock guard-banner cases). if [ -z "${FM_ROOT_OVERRIDE:-}" ]; then - FM_ROOT_OVERRIDE="$(fm_test_tmproot fm-wake-tangle-root)" + fm_test_tmproot FM_ROOT_OVERRIDE fm-wake-tangle-root export FM_ROOT_OVERRIDE fi @@ -32,12 +32,10 @@ fi # that channel, to exercise graceful degradation. Suites that do not source this # harness still cannot fire a real notification: the daemon defaults the seam to # "discard" whenever it is sourced (its library-mode guard). -# Create the recorder dir with mktemp directly (not fm_test_tmproot, whose -# first call installs an EXIT trap that, invoked inside a command-substitution -# subshell, would delete the dir on subshell exit). Register it for the same -# cleanup and install the trap in THIS shell if it is the first registration. +# Create the recorder dir directly because this helper needs a private path in +# addition to the suite's main temp root, then register it with the shared traps. _fm_wedge_rec_dir=$(mktemp -d "${TMPDIR:-/tmp}/fm-wedge-rec.XXXXXX") -if [ "${#FM_TEST_CLEANUP_DIRS[@]}" -eq 0 ]; then trap fm_test_cleanup EXIT; fi +fm_test_install_cleanup_traps FM_TEST_CLEANUP_DIRS+=("$_fm_wedge_rec_dir") cat > "$_fm_wedge_rec_dir/rec" <<'REC' #!/usr/bin/env bash From f24333c3802c7aac5ac2f46c21bb92f981db678d Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:45:15 +0000 Subject: [PATCH 2/4] no-mistakes(review): fix: namespace fm_test_tmproot locals to avoid caller-var collisions --- tests/fm-test-lib.test.sh | 13 +++++++++++++ tests/lib.sh | 10 +++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/tests/fm-test-lib.test.sh b/tests/fm-test-lib.test.sh index 8585e326d..1dd2a5984 100755 --- a/tests/fm-test-lib.test.sh +++ b/tests/fm-test-lib.test.sh @@ -74,5 +74,18 @@ test_term_runs_custom_exit_and_cleans_registered_root() { pass "TERM preserves a test-owned EXIT trap and cleans its registered root" } +test_output_var_named_root_is_assigned() { + ( + set -u + # shellcheck source=tests/lib.sh + . "$ROOT/tests/lib.sh" + fm_test_tmproot root fm-test-lib-collide + [ -n "${root:-}" ] || fail "output variable named 'root' was not assigned" + [ -d "$root" ] || fail "output variable named 'root' does not point at a temp dir" + ) || fail "fm_test_tmproot did not assign a caller variable named 'root'" + pass "fm_test_tmproot assigns a caller variable named 'root' without collision" +} + test_normal_exit_cleans_registered_root test_term_runs_custom_exit_and_cleans_registered_root +test_output_var_named_root_is_assigned diff --git a/tests/lib.sh b/tests/lib.sh index 229cbc14a..7f72b5586 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -96,18 +96,18 @@ fm_test_install_cleanup_traps() { } fm_test_tmproot() { - local output_var=${1:-} prefix=${2:-fm-test} root - case "$output_var" in + local _fm_test_tmproot_output_var=${1:-} _fm_test_tmproot_prefix=${2:-fm-test} _fm_test_tmproot_root + case "$_fm_test_tmproot_output_var" in ''|[0-9]*|*[!a-zA-Z0-9_]*) printf 'fm_test_tmproot: first argument must be an output variable name\n' >&2 return 2 ;; esac - root=$(mktemp -d "${TMPDIR:-/tmp}/${prefix}.XXXXXX") \ + _fm_test_tmproot_root=$(mktemp -d "${TMPDIR:-/tmp}/${_fm_test_tmproot_prefix}.XXXXXX") \ || fail "could not create test temp root under ${TMPDIR:-/tmp}" fm_test_install_cleanup_traps - FM_TEST_CLEANUP_DIRS+=("$root") - printf -v "$output_var" '%s' "$root" + FM_TEST_CLEANUP_DIRS+=("$_fm_test_tmproot_root") + printf -v "$_fm_test_tmproot_output_var" '%s' "$_fm_test_tmproot_root" } # --- fakebin / PATH shims --------------------------------------------------- From 0f6debe45979de50413aa848872ca2bf4fd1c424 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:48:19 +0000 Subject: [PATCH 3/4] no-mistakes(review): fix: drop leaking subshell in fm_test_tmproot collision regression --- tests/fm-test-lib.test.sh | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/fm-test-lib.test.sh b/tests/fm-test-lib.test.sh index 1dd2a5984..b9c7e1da7 100755 --- a/tests/fm-test-lib.test.sh +++ b/tests/fm-test-lib.test.sh @@ -75,14 +75,10 @@ test_term_runs_custom_exit_and_cleans_registered_root() { } test_output_var_named_root_is_assigned() { - ( - set -u - # shellcheck source=tests/lib.sh - . "$ROOT/tests/lib.sh" - fm_test_tmproot root fm-test-lib-collide - [ -n "${root:-}" ] || fail "output variable named 'root' was not assigned" - [ -d "$root" ] || fail "output variable named 'root' does not point at a temp dir" - ) || fail "fm_test_tmproot did not assign a caller variable named 'root'" + local root + fm_test_tmproot root fm-test-lib-collide + [ -n "${root:-}" ] || fail "output variable named 'root' was not assigned" + [ -d "$root" ] || fail "output variable named 'root' does not point at a temp dir" pass "fm_test_tmproot assigns a caller variable named 'root' without collision" } From 3f7b637f4bbcbceca93d8dc1b68a8c9998fc852d Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:15:10 +0000 Subject: [PATCH 4/4] no-mistakes(document): docs: add missing FM_LOCK_STEAL_MAX_DEPTH env var to configuration reference --- docs/configuration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/configuration.md b/docs/configuration.md index 9694fc9bf..4fe6a8ece 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -424,6 +424,7 @@ FMX_X_THREAD_MAX=25 # maximum messages in one auto-split reply thread FMX_FOLLOWUP_MAX_AGE_SECS=604800 # local window for posting X-mode completion follow-ups (7 days) FMX_FOLLOWUP_MAX_COUNT=3 # local cap on X-mode completion follow-ups per linked mention FM_LOCK_STALE_AFTER=2 # seconds before dead-pid lock records can be reclaimed; mid-acquire locks keep at least 2s grace +FM_LOCK_STEAL_MAX_DEPTH=8 # hard cap on nested stale-lock steal recursion; acquisition fails loudly (rc 2) past this depth instead of recursing unbounded FM_GUARD_GRACE=300 # seconds before guard warnings, arm health checks, and the primary turn-end guard treat a watcher beacon as stale FM_ARM_CONFIRM_TIMEOUT=10 # seconds fm-watch-arm waits to confirm a fresh watcher before reporting FAILED FM_ARM_ATTACH_POLL=0.5 # seconds between checks while fm-watch-arm is attached to an existing healthy watcher cycle