From 8fb67cb01e1656928da14b1634e9c34ebe1ba3bf Mon Sep 17 00:00:00 2001 From: MLA82 <212341412+MLA82@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:23:43 +0200 Subject: [PATCH] fix(bin): harden lock waits, install checks, and test aggregation against silent failures - The wake acknowledgement, branch-grant, and queued-key callers wait for the wake-queue lock through fm_lock_acquire_wait_bounded (default 30s, FM_WAKE_QUEUE_LOCK_WAIT) and refuse instead of mutating queue state without the lock. fm_lock_acquire_wait keeps its contract of waiting indefinitely and never failing, so its other callers are unchanged. - cmux surface-existence check captures the CLI call's exit status before piping to jq -e, so a failed CLI call is no longer read as "surface exists" when jq treats empty stdin as success. - fm-install-herdr.sh and fm-install-treehouse.sh capture --version stderr separately from stdout, so incidental stderr output cannot corrupt the parsed version, and include it in the failure message. - fm-test-run.sh aggregation skips unreadable or truncated per-lane timing artifacts with a warning instead of aborting the aggregate step; fm-afk-return.sh print_evidence propagates a write failure. - Docs and tests cover each change. --- bin/backends/cmux.sh | 11 +++++- bin/fm-afk-return.sh | 5 ++- bin/fm-install-herdr.sh | 15 ++++++-- bin/fm-install-treehouse.sh | 12 +++++- bin/fm-test-run.sh | 11 +++++- bin/fm-wake-drain.sh | 13 +++++-- bin/fm-wake-grant.sh | 8 ++-- bin/fm-wake-lib.sh | 8 +++- docs/configuration.md | 1 + docs/watcher-continuity.md | 2 +- tests/fm-backend-cmux.test.sh | 35 ++++++++++++++++++ tests/fm-test-run.test.sh | 34 +++++++++++++++++ tests/fm-watcher-lock.test.sh | 69 +++++++++++++++++++++++++++++++++++ 13 files changed, 203 insertions(+), 21 deletions(-) diff --git a/bin/backends/cmux.sh b/bin/backends/cmux.sh index 747d3fc2ccd..4d2d36fa95e 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -398,8 +398,15 @@ fm_backend_cmux_parse_target() { # # (fm_backend_zellij_pane_exists) rather than the design sketch's original # read-screen-based suggestion. fm_backend_cmux_surface_exists() { # - local wsid=$1 sfid=$2 - fm_backend_cmux_cli list-panes --workspace "$wsid" --json --id-format uuids 2>/dev/null \ + local wsid=$1 sfid=$2 out + # Capture and check the CLI call's own exit status before piping to jq: a + # bare `cli ... | jq -e ...` pipe (without pipefail) takes its exit status + # from jq alone, and jq 1.6 (still the apt-installed default on several + # supported Linux distros) reports success (exit 0) for `-e` on completely + # empty stdin instead of jq 1.7+'s failure (exit 4) - so a failed, + # output-less list-panes call would misreport the surface as existing. + out=$(fm_backend_cmux_cli list-panes --workspace "$wsid" --json --id-format uuids 2>/dev/null) || return 1 + printf '%s' "$out" \ | jq -e --arg s "$sfid" '[.panes[]? | select(.surface_ids // [] | index($s))] | length > 0' >/dev/null 2>&1 } diff --git a/bin/fm-afk-return.sh b/bin/fm-afk-return.sh index 3b38defc916..3b07ca305ab 100755 --- a/bin/fm-afk-return.sh +++ b/bin/fm-afk-return.sh @@ -231,11 +231,12 @@ write_gate() { # } print_evidence() { # - local file=$1 kind text + local file=$1 kind text status=0 while IFS="$(printf '\t')" read -r tag kind text; do [ "$tag" = evidence ] || continue - printf 'catch-up %s: %s\n' "$kind" "$text" + printf 'catch-up %s: %s\n' "$kind" "$text" || status=1 done < "$file" + return "$status" } print_blockers() { # diff --git a/bin/fm-install-herdr.sh b/bin/fm-install-herdr.sh index 6ce19637613..4d0c6bea7c8 100755 --- a/bin/fm-install-herdr.sh +++ b/bin/fm-install-herdr.sh @@ -76,13 +76,20 @@ fi mkdir -p "$DESTINATION" install -m 0755 "$TMP/$ASSET" "$DESTINATION/herdr" -# Post-install version and protocol gates (no floating latest). -installed_version=$("$DESTINATION/herdr" --version 2>/dev/null | awk '{print $2; exit}') +# Post-install version and protocol gates (no floating latest). stderr is +# captured to its own file rather than merged with 2>&1: merging would pollute +# a successful call's parsed stdout (version_output/status) with any incidental +# stderr text (a deprecation notice, update check, telemetry banner, etc.), +# breaking the awk/jq parse below even on a healthy install. +version_output=$("$DESTINATION/herdr" --version 2>"$TMP/herdr-version.err") || { + die "'$DESTINATION/herdr --version' exited $? with output: $(cat "$TMP/herdr-version.err" 2>/dev/null)" +} +installed_version=$(printf '%s' "$version_output" | awk '{print $2; exit}') [ "$installed_version" = "$FM_HERDR_CI_VERSION" ] \ || die "installed herdr version is '${installed_version:-}', expected exact pin $FM_HERDR_CI_VERSION" -status=$("$DESTINATION/herdr" status --json 2>/dev/null) \ - || die "could not run 'herdr status --json' after install" +status=$("$DESTINATION/herdr" status --json 2>"$TMP/herdr-status.err") \ + || die "could not run 'herdr status --json' after install: $(cat "$TMP/herdr-status.err" 2>/dev/null)" protocol=$(printf '%s' "$status" | jq -r '.client.protocol // empty' 2>/dev/null) \ || die "jq is required to parse herdr status after install" case "$protocol" in diff --git a/bin/fm-install-treehouse.sh b/bin/fm-install-treehouse.sh index 9d181439c87..50f953cad9f 100755 --- a/bin/fm-install-treehouse.sh +++ b/bin/fm-install-treehouse.sh @@ -81,12 +81,20 @@ fi mkdir -p "$DESTINATION" install -m 0755 "$BIN" "$DESTINATION/treehouse" -installed_version=$("$DESTINATION/treehouse" --version 2>/dev/null | tr -d '[:space:]') +# stderr is captured to its own file rather than merged with 2>&1: merging +# would pollute a successful call's parsed stdout (version_output) with any +# incidental stderr text (a deprecation notice, update check, etc.), which +# tr -d '[:space:]' below would then splice directly onto the version string +# with no separator, breaking the pin comparison even on a healthy install. +version_output=$("$DESTINATION/treehouse" --version 2>"$TMP/treehouse-version.err") || { + die "'$DESTINATION/treehouse --version' exited $? with output: $(cat "$TMP/treehouse-version.err" 2>/dev/null)" +} +installed_version=$(printf '%s' "$version_output" | tr -d '[:space:]') # treehouse prints "v2.0.1" (leading v) on --version. case "$installed_version" in "v${FM_TREEHOUSE_CI_VERSION}"|"${FM_TREEHOUSE_CI_VERSION}") ;; *) - die "installed treehouse version is '${installed_version:-}', expected exact pin v${FM_TREEHOUSE_CI_VERSION}" + die "installed treehouse version is '${installed_version:-}', expected exact pin v${FM_TREEHOUSE_CI_VERSION}. treehouse --version said: ${version_output:-}" ;; esac diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 44e8da93bcc..913c876d0ef 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -1165,7 +1165,16 @@ skipped = 0 total = 0 wall_ms = 0 for path in inputs: - doc = json.loads(path.read_text(encoding="utf-8")) + # A lane artifact can be truncated when its own job is killed mid-write + # (host memory pressure, a hang timeout). This aggregate step runs with + # if: always() precisely so per-lane failures still get reported here; + # skip an unreadable lane instead of taking the whole aggregate down + # with it. + try: + doc = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + print(f"fm-test-run.sh: skipping unreadable timing artifact {path}: {exc}", file=sys.stderr) + continue summary = doc.get("summary") or {} lane = { "path": str(path), diff --git a/bin/fm-wake-drain.sh b/bin/fm-wake-drain.sh index 8268bb917fa..2e3688daf5c 100755 --- a/bin/fm-wake-drain.sh +++ b/bin/fm-wake-drain.sh @@ -9,7 +9,8 @@ # Keep sequence-bound row consumption independent from generation-bound episode # retirement; docs/watcher-continuity.md owns the recovery contract. # FM_STATUS_PRESENTATION_LOCK_TIMEOUT sets the positive whole-second wait for -# presentation-path locks (default 10); queue mutation locks remain blocking. +# presentation-path locks (default 10); the acknowledgement queue-lock acquires +# wait FM_WAKE_QUEUE_LOCK_WAIT seconds (default 30) and refuse on timeout. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -625,7 +626,10 @@ trap 'exit 130' INT trap 'exit 143' TERM if [ -n "$ACK_THROUGH" ]; then - fm_lock_acquire_wait "$FM_WAKE_QUEUE_LOCK" + fm_lock_acquire_wait_bounded "$FM_WAKE_QUEUE_LOCK" "$FM_WAKE_QUEUE_LOCK_WAIT" || { + printf 'wake drain: queue lock could not be acquired safely\n' >&2 + exit 1 + } elif fm_lock_acquire_wait_bounded "$FM_WAKE_QUEUE_LOCK" "$PRESENTATION_LOCK_TIMEOUT"; then : else @@ -681,7 +685,10 @@ if [ -n "$ACK_THROUGH" ]; then echo "wake drain: inactive outcome receipt could not be recorded safely" >&2 exit 1 fi - fm_lock_acquire_wait "$FM_WAKE_QUEUE_LOCK" + fm_lock_acquire_wait_bounded "$FM_WAKE_QUEUE_LOCK" "$FM_WAKE_QUEUE_LOCK_WAIT" || { + printf 'wake drain: queue lock could not be acquired safely\n' >&2 + exit 1 + } DRAIN_LOCK_HELD=true DRAIN_TMP=$(mktemp "$STATE/.wake-queue.ack.XXXXXX") || exit 1 chmod 0600 "$DRAIN_TMP" || exit 1 diff --git a/bin/fm-wake-grant.sh b/bin/fm-wake-grant.sh index bdff2fd4ead..7eb8fec4604 100755 --- a/bin/fm-wake-grant.sh +++ b/bin/fm-wake-grant.sh @@ -41,7 +41,7 @@ case "${1:-}" in TMP=$(mktemp "$STATE/.branch-eligible-owner.tmp.XXXXXX") || exit 1 printf '%s\n%s\n%s\n%s\n' fm-branch-eligible-owner-v1 "$pid" "$identity" "$generation" > "$TMP" || exit 1 chmod 0600 "$TMP" || exit 1 - fm_lock_acquire_wait "$FM_WAKE_QUEUE_LOCK" + fm_lock_acquire_wait_bounded "$FM_WAKE_QUEUE_LOCK" "$FM_WAKE_QUEUE_LOCK_WAIT" || exit 1 LOCK_HELD=true [ "$(fm_pid_identity "$pid" 2>/dev/null || true)" = "$identity" ] || exit 1 rm -f -- "$BRANCH_ROWS" || exit 1 @@ -57,7 +57,7 @@ case "${1:-}" in printf '%s\n' "$@" > "$TMP" || exit 1 chmod 0600 "$TMP" || exit 1 rows_valid "$TMP" || exit 2 - fm_lock_acquire_wait "$FM_WAKE_QUEUE_LOCK" + fm_lock_acquire_wait_bounded "$FM_WAKE_QUEUE_LOCK" "$FM_WAKE_QUEUE_LOCK_WAIT" || exit 1 LOCK_HELD=true owner_matches '' "$generation" || exit 1 replace=1 @@ -86,7 +86,7 @@ case "${1:-}" in release) generation=${2:-} [ "$#" -eq 2 ] || exit 2 - fm_lock_acquire_wait "$FM_WAKE_QUEUE_LOCK" + fm_lock_acquire_wait_bounded "$FM_WAKE_QUEUE_LOCK" "$FM_WAKE_QUEUE_LOCK_WAIT" || exit 1 LOCK_HELD=true owner_matches '' "$generation" || exit 1 rm -f -- "$BRANCH_ROWS" || exit 1 @@ -95,7 +95,7 @@ case "${1:-}" in pid=${2:-} generation=${3:-} [ "$#" -eq 3 ] || exit 2 - fm_lock_acquire_wait "$FM_WAKE_QUEUE_LOCK" + fm_lock_acquire_wait_bounded "$FM_WAKE_QUEUE_LOCK" "$FM_WAKE_QUEUE_LOCK_WAIT" || exit 1 LOCK_HELD=true owner_matches "$pid" "$generation" || exit 1 rm -f -- "$BRANCH_ROWS" "$BRANCH_OWNER" || exit 1 diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh index 6a7590cafe3..0339735e48a 100755 --- a/bin/fm-wake-lib.sh +++ b/bin/fm-wake-lib.sh @@ -9,6 +9,9 @@ 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}" +# Positive whole seconds the queue-lock callers that check the result of +# fm_lock_acquire_wait_bounded wait before refusing; any other value makes them refuse. +FM_WAKE_QUEUE_LOCK_WAIT="${FM_WAKE_QUEUE_LOCK_WAIT:-30}" # 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. @@ -1056,7 +1059,8 @@ _fm_lock_acquire_wait_handoff() { # # Use it where a caller must refuse rather than block: wake presentation, and # the guarded remote link clear, whose whole contract is to return a # reconciliation refusal instead of wedging an unattended close. -# Mutation-critical callers that can safely block keep fm_lock_acquire_wait. +# Callers that cannot check a result keep fm_lock_acquire_wait, which waits +# indefinitely and cannot fail. fm_lock_acquire_wait_bounded() { local lockdir=$1 seconds=$2 caller_pid rc owner_pid case "$seconds" in ''|*[!0-9]*|0) return 2 ;; esac @@ -1858,7 +1862,7 @@ fm_wake_queued_keys() { signal|stale|check|heartbeat) ;; *) printf 'fm_wake_queued_keys: invalid wake kind: %s\n' "$kind" >&2; return 2 ;; esac - fm_lock_acquire_wait "$FM_WAKE_QUEUE_LOCK" + fm_lock_acquire_wait_bounded "$FM_WAKE_QUEUE_LOCK" "$FM_WAKE_QUEUE_LOCK_WAIT" || return 1 fm_wake_queued_keys_locked "$kind" fm_lock_release "$FM_WAKE_QUEUE_LOCK" } diff --git a/docs/configuration.md b/docs/configuration.md index e21c13f8799..9e685f73ce0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1118,6 +1118,7 @@ FMX_FOLLOWUP_MAX_AGE_SECS=604800 # local window for posting Relay completion f FMX_FOLLOWUP_MAX_COUNT=3 # local cap on Relay completion follow-ups per linked mention FM_PF_RETRY_BACKOFF_SECS=900 # seconds before the next attempt after a retryable promised-public-reply delivery error FM_LOCK_STALE_AFTER=2 # grace seconds for missing or nonnumeric lock-owner PIDs (minimum 2s); dead numeric PIDs have no age grace +FM_WAKE_QUEUE_LOCK_WAIT=30 # positive whole seconds the wake acknowledgement, branch-grant, and queued-key callers wait for the queue lock before refusing FM_GUARD_GRACE=300 # beacon freshness threshold for guard verdicts, arm health checks, and the primary turn-end guard; see docs/turnend-guard.md for model-aware exceptions FM_CLAUDE_AUTOARM_ATTEMPTS=2 # bounded Stop-owned arm attempts per Claude auto-arm cycle; accepted values are 1, 2, or 3 FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=800 # milliseconds the --claude turn-end guard waits for watcher health, an open Stop auto-arm generation claim, or a fresh epoch before deciding recovery ownership or failure progression diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 9f79edf94cd..e95c5800e87 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -69,7 +69,7 @@ An acknowledged episode does not freeze the generation, because the next downtim Every presented row is claimed to exactly one actor under the durable queue lock. An ordinary presentation drain bounds both its initial queue-lock acquire and its later status-presentation-lock acquire at the deadline owned by the script header. A live initial queue-lock holder produces one PID-naming advisory and skips the whole drain before any claim or mutation, while a live status-presentation-lock holder produces one such advisory after raw wake presentation and leaves status annotations, sections, and cursors retriable on the next drain. -Acknowledgement invocations and every other mutation-critical queue-lock acquire retain blocking semantics, so acknowledgement atomicity is unchanged. +Acknowledgement invocations, the branch-grant helper, and the queued-key read wait for the queue lock through `fm_lock_acquire_wait_bounded` at the 30-second default of `FM_WAKE_QUEUE_LOCK_WAIT` and refuse rather than proceed without it, so acknowledgement atomicity is unchanged; every other queue-lock acquire keeps the indefinite wait of `fm_lock_acquire_wait`. Main records its presented set in `state/.main-eligible-rows`. A branch grant is published through `bin/fm-wake-grant.sh` under that same lock in `state/.branch-eligible-rows`, bound to the live branch process and extension generation recorded in `state/.branch-eligible-owner`, and publication is refused if main already claimed any requested row. A main drain validates that owner evidence under the queue lock and reclaims the grant when its process is gone or its identity no longer matches. diff --git a/tests/fm-backend-cmux.test.sh b/tests/fm-backend-cmux.test.sh index 16875a95cd1..ded4acd6d99 100755 --- a/tests/fm-backend-cmux.test.sh +++ b/tests/fm-backend-cmux.test.sh @@ -511,6 +511,40 @@ test_target_ready_fails_when_target_absent() { pass "fm_backend_cmux_target_ready: fails when the workspace/surface is not found (list-panes structural check)" } +test_target_ready_fails_when_list_panes_errors_empty() { + local dir fb status real_jq + dir="$TMP_ROOT/ready-list-panes-error"; mkdir -p "$dir/responses" + fb=$(make_cmux_fakebin "$dir") + real_jq=$(command -v jq) + # 1: list-panes --json --id-format uuids -> exits nonzero with no stdout + # (a failed call, not "no matching pane"). Regression coverage for the + # incident where a bare `cli ... | jq -e ...` pipe took its exit status + # from jq alone, and jq 1.6 treated empty stdin as success under -e, so a + # failed list-panes call misreported the surface as existing. A real jq + # 1.7+ already refuses empty stdin under -e, which would mask the bug on + # this machine, so this stub jq mimics 1.6's `-e`-on-empty-stdin=success + # quirk to make the regression check version-independent: it fails unless + # the code checks the CLI's own exit status before ever handing off to jq. + cat > "$fb/jq" < "$dir/responses/1.exit" + PATH="$fb:$PATH" FM_CMUX_LOG="$dir/log" FM_CMUX_RESPONSES="$dir/responses" \ + bash -c '. "$0/bin/backends/cmux.sh"; fm_backend_cmux_target_ready "aaaaaaaa-0000-0000-0000-000000000000:bbbbbbbb-1111-1111-1111-111111111111"' "$ROOT" + status=$? + [ "$status" -ne 0 ] || fail "target_ready should fail when list-panes itself errors out, not just when it reports no match" + pass "fm_backend_cmux_target_ready: fails when list-panes itself errors (not just when it reports no match)" +} + test_target_ready_checks_expected_label() { local dir fb title dir="$TMP_ROOT/ready-label-ok"; mkdir -p "$dir/responses" @@ -1131,6 +1165,7 @@ test_ensure_running_fails_fast_on_unauth_without_launching test_create_task_refuses_duplicate_label test_create_task_creates_and_parses_ids test_target_ready_fails_when_target_absent +test_target_ready_fails_when_list_panes_errors_empty test_target_ready_checks_expected_label test_target_ready_rejects_label_mismatch test_capture_trims_locally diff --git a/tests/fm-test-run.test.sh b/tests/fm-test-run.test.sh index b3100151480..9189434b6ec 100755 --- a/tests/fm-test-run.test.sh +++ b/tests/fm-test-run.test.sh @@ -1736,6 +1736,39 @@ assert len(doc["scripts"])==3 pass "aggregate-json merges lane timing artifacts" } +test_aggregate_json_skips_unreadable_lane() { + local tmp rc out + tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-test-run-aggjson-bad.XXXXXX") + cat >"$tmp/good.json" <<'JSON' +{ + "run_id": "good", + "selection": "lane=portable-serial-1of4", + "started_at": "2026-07-22T00:00:00Z", + "finished_at": "2026-07-22T00:01:00Z", + "summary": {"total": 1, "failed": 0, "skipped_gate": 0, "duration_ms": 1000}, + "scripts": [{"path": "tests/a.test.sh", "family": "pure-contract-unit", "duration_ms": 1000, "exit": 0, "gate_skip": false}] +} +JSON + # Simulates a lane artifact truncated when its own job was killed + # (OOM, hang timeout) mid-write, before the closing brace landed. + printf '{"run_id": "truncated", "summary": {"total": 1' >"$tmp/truncated.json" + + rc=0 + out=$("$RUNNER" --aggregate-json "$tmp/out.json" "$tmp/good.json" "$tmp/truncated.json" 2>"$tmp/err") || rc=$? + [ "$rc" -eq 0 ] || { rm -rf "$tmp"; fail "aggregate-json must not crash on an unreadable lane, got rc=$rc: $(cat "$tmp/err")"; } + assert_contains "$out" "FM_TEST_AGGREGATE lanes=1 total=1 failed=0" "aggregate summary line must count only the readable lane" + assert_grep "skipping unreadable timing artifact" "$tmp/err" "no warning was printed for the unreadable lane" + python3 -c ' +import json,sys +doc=json.load(open(sys.argv[1])) +assert doc["summary"]["lanes"]==1 +assert len(doc["lanes"])==1 +assert doc["lanes"][0]["run_id"]=="good" +' "$tmp/out.json" || { rm -rf "$tmp"; fail "aggregate JSON must contain only the readable lane"; } + rm -rf "$tmp" + pass "aggregate-json skips an unreadable lane instead of crashing the whole aggregate" +} + test_list_all_exact_suite_coverage test_family_selection test_single_script_selection @@ -1775,3 +1808,4 @@ test_max_wall_ms_is_a_result_not_advice test_jobs_parallel_scheduler_and_failure_propagation test_herdr_ci_family_run_has_a_step_timeout test_aggregate_json +test_aggregate_json_skips_unreadable_lane diff --git a/tests/fm-watcher-lock.test.sh b/tests/fm-watcher-lock.test.sh index 37af8ac641e..ef067f9222b 100755 --- a/tests/fm-watcher-lock.test.sh +++ b/tests/fm-watcher-lock.test.sh @@ -280,6 +280,73 @@ test_lock_single_winner_under_concurrency() { pass "concurrent fm_lock_try_acquire yields exactly one winner" } +test_lock_acquire_wait_waits_for_a_live_holder() { + local dir state lockdir live rc err start end + dir=$(make_case lock-acquire-wait-live-holder) + state="$dir/state" + lockdir="$state/.contend.lock" + err="$dir/err" + sleep 300 & + live=$! + mkdir "$lockdir" + printf '%s\n' "$live" > "$lockdir/pid" + (sleep 2; kill "$live" 2>/dev/null || true) & + rc=0 + start=$(date +%s) + FM_STATE_OVERRIDE="$state" bash -c ' + . "$1" + fm_lock_acquire_wait "$2" + ' _ "$LIB" "$lockdir" 2>"$err" || rc=$? + end=$(date +%s) + kill "$live" 2>/dev/null || true + wait "$live" 2>/dev/null || true + [ "$rc" -eq 0 ] || fail "fm_lock_acquire_wait should acquire once the holder is gone, got rc=$rc" + [ "$((end - start))" -ge 1 ] || fail "fm_lock_acquire_wait returned before the live holder was gone" + [ ! -s "$err" ] || fail "fm_lock_acquire_wait wrote to stderr on a successful wait: $(cat "$err")" + pass "fm_lock_acquire_wait: keeps waiting for a live holder and cannot fail" +} + +test_queue_lock_callers_refuse_when_the_lock_stays_held() { + local dir state lockdir live rc err keys + dir=$(make_case queue-lock-callers-refuse) + state="$dir/state" + lockdir="$state/.wake-queue.lock" + err="$dir/err" + sleep 300 & + live=$! + mkdir "$lockdir" + printf '%s\n' "$live" > "$lockdir/pid" + + rc=0 + FM_STATE_OVERRIDE="$state" FM_WAKE_QUEUE_LOCK_WAIT=1 "$ROOT/bin/fm-wake-grant.sh" \ + activate "$live" gen-1 2>/dev/null || rc=$? + [ "$rc" -eq 1 ] || fail "fm-wake-grant activate should refuse while the queue lock is held, got rc=$rc" + [ ! -e "$state/.branch-eligible-owner" ] \ + || fail "fm-wake-grant activate recorded an owner without holding the queue lock" + + rc=0 + FM_STATE_OVERRIDE="$state" FM_WAKE_QUEUE_LOCK_WAIT=1 "$DRAIN" \ + --ack-through 0 --recovery-generation gen-1 >/dev/null 2>"$err" || rc=$? + [ "$rc" -eq 1 ] || fail "fm-wake-drain --ack-through should refuse while the queue lock is held, got rc=$rc" + assert_grep "queue lock could not be acquired safely" "$err" \ + "fm-wake-drain --ack-through did not report the lock it could not acquire" + + rc=0 + keys=$(FM_STATE_OVERRIDE="$state" FM_WAKE_QUEUE_LOCK_WAIT=1 bash -c ' + . "$1" + fm_wake_queued_keys check + ' _ "$LIB" 2>/dev/null) || rc=$? + [ "$rc" -ne 0 ] || fail "fm_wake_queued_keys should fail while the queue lock is held" + [ -z "$keys" ] || fail "fm_wake_queued_keys printed keys without holding the queue lock: $keys" + + kill "$live" 2>/dev/null || true + wait "$live" 2>/dev/null || true + FM_STATE_OVERRIDE="$state" FM_WAKE_QUEUE_LOCK_WAIT=1 "$ROOT/bin/fm-wake-grant.sh" \ + activate $$ gen-1 2>/dev/null || fail "fm-wake-grant activate should succeed once the queue lock is free" + [ -s "$state/.branch-eligible-owner" ] || fail "fm-wake-grant activate recorded no owner after the lock was free" + pass "queue-lock callers wait boundedly and refuse when the lock stays held" +} + test_lock_steals_dead_pid_lock() { local dir state lockdir dead rc newpid dir=$(make_case lock-dead-steal) @@ -1172,6 +1239,8 @@ test_stale_watch_reclaim_publishes_before_clear test_live_stale_watch_lock_is_actionable test_guard_warnings test_lock_single_winner_under_concurrency +test_lock_acquire_wait_waits_for_a_live_holder +test_queue_lock_callers_refuse_when_the_lock_stays_held test_lock_steals_dead_pid_lock test_lock_stale_steal_single_winner_under_concurrency test_lock_live_steal_mutex_is_not_reclaimed