Skip to content

fix(bin): stop lock waits in a removed home from spinning, and order pi-watch recovery tests deterministically - #32

Merged
SchokoShake merged 3 commits into
mainfrom
fm/fm-pi-watch-extension-suite-flaky
Sep 17, 2026
Merged

SchokoShake merged 3 commits into
mainfrom
fm/fm-pi-watch-extension-suite-flaky

Conversation

@SchokoShake

Copy link
Copy Markdown
Owner

Intent

Fix the root cause of the flaky tests/fm-pi-watch-extension.test.sh so it passes deterministically under load and in any CI shard - without widening timeouts.

Context from the queued backlog item fm-pi-watch-extension-suite-flaky: serial runs were clean while concurrent runs failed a random case (OpenCode external-healthy output, Pi hung-successor, OpenCode fleet-lock), pointing at cross-case shared state or a wall-clock readiness race. Widening readiness budgets was explicitly banned: PR 26 had tried six readiness budgets from 250ms to 2s, that did not stop the flake, and it was reverted. New evidence 2026-09-17: #26 adds two serial suites, which rebalanced CI's shard partition and moved this suite into portable-serial-4of4, where it failed 3 of 3 attempts on CI run 35206607313, every time in 'Pi must deliver the actionable wake after bounded hung-successor recovery: expected exit 0, got 1', with the suite at main's exact bytes. Required: reproduce locally (shard-4 neighbours or comparable concurrent load) before changing anything; find the shared state or readiness race; make readiness deterministic with an explicit ready signal or ordering, not a longer budget; prove it (the reproduced failing configuration passes repeatedly, N of N; serial still passes; main's timing budgets unchanged). If the defect is in product code rather than the test, fix it there with a regression test. Once this lands, PR 26's CI rebases onto it. Constraints followed: never run a test suite with a live FM_HOME exported; never stop, restart, or update the shared no-mistakes daemon.

Root cause 1, cross-suite shared state (product defect, fixed in product with regression tests): tests/fm-startup-network.test.sh leaves five detached startup-network workers waiting to deliver results to the test shell's claim. Its EXIT cleanup deletes their homes; fm_lock_try_acquire then treated each absent lock as stale and recursed through ever-deeper .steal locks without bound (measured 231 levels in 5s, about one CPU core per worker for minutes), and fm_lock_acquire_wait would otherwise wait forever. CI logs for both main's and PR 26's shard 4 show every suite after fm-startup-network running 1.3-2.8x slower on the 4-vCPU runner; PR 26's rebalance placed the Pi suite in that window. Decisions: fm_lock_try_acquire refuses a lock that does not exist instead of stealing it (recovery of an existing stale lock is unchanged). fm_lock_acquire_wait now fails once the lock's parent directory no longer exists; this is deliberately in the shared primitive (single owner) rather than a startup-network-local helper, after auditing callers: 30 call sites already handle a failed wait, the rest act only on the directory that is gone, and before this change such a wait could never succeed anyway. fm_wake_append stops at that failure, and bin/fm-startup-network.sh call sites handle a failed wait (the lease path also removes its temp files). A worker-local helper was tried first and dropped because a worker inside fm_wake_append still stranded (observed under 4-CPU pinning). Regression tests: tests/fm-watcher-lock.test.sh proves both lock calls in a removed directory refuse promptly (bounded with fm_run_timed so a regression fails instead of hanging); tests/fm-startup-network.test.sh proves a worker exits when its home is removed while waiting on its publication lock, and while delivering its wake (made deterministic by holding the relevant lock and moving the home atomically). Each fails on the previous code.

Root cause 2, readiness races inside the suite (test defects; the Pi extension and OpenCode plugins are intentionally untouched): the Pi and OpenCode hung, unretired, and late-close recovery cases used a 250ms readiness budget that lapsed before a login-shell successor ran its first line (CI's hidden message was 'expected one successor plus two retries, got 2'); the OpenCode fleet-lock case slept 120ms as a stand-in for the plugin's unowned launch settling, so the next idle event joined the stale launch and never armed; the stub turn-end guards exited without reading the payload the plugin writes, which crashed the test with an unhandled EPIPE under load. Decisions: the recovery cases use node:test MockTimers through a generated recovery-clock helper, so each readiness, retirement, or retry budget lapses only after the child it measures has visibly reached the state under test (fixtures record themselves after installing their TERM trap), with arm-child close tracking via the public node:diagnostics_channel child_process channel; the fleet-lock case awaits the plugin's in-flight launch through its existing shared coordinator and asserts read-only; stub guards read their payload as bin/fm-turnend-guard.sh does; the suite reaps processes still running from its own unique temp root on exit (fixed-string match, SIGKILL because some fixtures ignore TERM) so a failing case cannot load later suites. No budget value, plugin default, or wait bound changed; the only removed wait is the racing 120ms sleep. Works on Node 22.18 and 24 (the suite's existing NODE_NO_WARNINGS=1 silences Node 22's MockTimers experimental warning).

Evidence gathered locally: with 20 CPU spinners on the suite's CPU, main's recovery cases passed 0 or 1 of 3 each (Pi hung, unretired, late; OpenCode hung, unretired, late, fleet-lock), reproducing CI's failure; on 4 CPUs saturated by real leaked workers main's fleet-lock case passed 3/10. With the fix: full suite under the 20-spinner load 5/5, under 4-CPU real leak 10/10, eight concurrent copies times three rounds 24/24, serial 5/5, Node 22.18 30/30, and PR 26's shard-4 suite order (pinned to 4 CPUs and unpinned) leaves 0 workers behind with the Pi suite passing. Known unrelated local-only failures that also fail on main here: fm-on.test.sh (tool PATH), fm-cursor-primary.test.sh (no C compiler), the session-start Herdr husk case, and load-sensitive tests/fm-watch-arm.test.sh. Out-of-scope follow-ups deliberately not done: plugin writes to hook stdin lack an error handler, and tests/fm-watch-arm.test.sh has its own load-sensitive waits.

What Changed

  • Lock primitive (bin/fm-wake-lib.sh):
    • fm_lock_try_acquire no longer treats a lock that does not exist as stale. It retries creation once, which covers a holder releasing mid-acquisition, and then refuses. Previously it recursed through ever-deeper .steal locks without bound.
    • fm_lock_acquire_wait now fails once the lock's parent directory is gone instead of waiting forever.
    • fm_wake_append stops at that failure.
    • Every fm_lock_acquire_wait call in bin/fm-startup-network.sh handles a failed wait. The sweep-lease path also removes its temp files.
    • bin/fm-lock.sh fails closed with a read-only error when it cannot take the fleet-lock claim mutex.
  • Regression tests:
    • tests/fm-watcher-lock.test.sh covers two cases, both bounded with fm_run_timed. Both lock calls refuse promptly in a removed directory without recreating it, and a lock released mid-acquisition is taken with exactly one retry.
    • tests/fm-startup-network.test.sh covers three cases. A worker exits when its home is moved away while it waits on its publication lock. A worker exits when its home is moved away while it delivers its wake. A fleet-lock takeover that loses its home while queued on the claim mutex exits 1 with the read-only error.
  • tests/fm-pi-watch-extension.test.sh:
    • The Pi and OpenCode hung, unretired, and late-close recovery cases now run on node:test MockTimers through a generated recovery-clock.mjs helper. It tracks arm-child closes through the child_process diagnostics channel. Each readiness, retirement, or retry budget lapses only after the child it measures has visibly started or closed. No budget value changed.
    • The OpenCode fleet-lock case awaits the plugin's in-flight unowned launch through its shared coordinator and asserts read-only. This replaces the 120ms sleep.
    • The stub turn-end guards now read their stdin payload.
    • The suite's EXIT trap SIGKILLs any process still running from its own temp root.
    • The plugins themselves are unchanged.

Risk Assessment

✅ Low: The fix-round changes are small and do what was prescribed (the fm_lock_try_acquire retry cannot recurse or loop, fm-lock.sh exits 1 with a read-only error before CLAIM_LOCK_HELD is set, and an empty temp root fails the suite before the reaper trap is installed), each has a regression test that runs the code rather than reading source, no timing budget, wait bound or plugin code changed, and the one remaining issue affects only test cleanup when the suite is interrupted.

Testing

I first reproduced the failure on the base commit under comparable load. With 20 CPU spinners pinned to the suite's CPU, main's Pi suite failed 3/3 in CI's exact hung-successor case. In the shard-4 order on 4 CPUs (fm-startup-network, then the Pi suite) it failed 3/3 in the fleet-lock case, with 5 leaked workers still running. At the target commit the same configurations passed every time: 5/5 under the spinner load, 3/3 in shard order with no workers left behind, 24/24 across eight concurrent copies over three rounds, and 5/5 serial. The new lock and startup-network regression tests pass on the fix and fail on the previous product code. The configured timing budgets are identical to main's, the only removed wait is the racing 120ms sleep, and no plugin file changed. Node 22 could not be checked locally because only Node 24 is installed. There is no UI surface, so the evidence is CLI transcripts.

Evidence: Base (main) Pi suite under the 20-spinner pinned load fails 3/3 in CI's failing case

Source: Base (main) Pi suite under the 20-spinner pinned load fails 3/3 in CI's failing case

== BASE 2804ff7 (main): tests/fm-pi-watch-extension.test.sh pinned to cpu 5 with 20 spinners, 3 runs run 1: FAIL rc=1 ok=5 28s not ok - Pi must deliver the actionable wake after bounded hung-successor recovery: expected exit 0, got 1 run 2: FAIL rc=1 ok=5 31s not ok - Pi must deliver the actionable wake after bounded hung-successor recovery: expected exit 0, got 1 run 3: FAIL rc=1 ok=5 30s not ok - Pi must deliver the actionable wake after bounded hung-successor recovery: expected exit 0, got 1 == BASE 2804ff7 (main) result: 0 of 3 passed

== BASE 2804ff7 (main): tests/fm-pi-watch-extension.test.sh pinned to cpu 5 with 20 spinners, 3 runs (fm-base-2804ff7.2660555, node v24.18.0)
run 1: FAIL rc=1 ok=5 28s
    not ok - Pi must deliver the actionable wake after bounded hung-successor recovery: expected exit 0, got 1
run 2: FAIL rc=1 ok=5 31s
    not ok - Pi must deliver the actionable wake after bounded hung-successor recovery: expected exit 0, got 1
run 3: FAIL rc=1 ok=5 30s
    not ok - Pi must deliver the actionable wake after bounded hung-successor recovery: expected exit 0, got 1
== BASE 2804ff7 (main) result: 0 of 3 passed
Evidence: Target Pi suite under the same load passes 5/5

Source: Target Pi suite under the same load passes 5/5

== TARGET 651212b (fix): tests/fm-pi-watch-extension.test.sh pinned to cpu 11 with 20 spinners, 5 runs (651212b, node v24.18.0) run 1: PASS rc=0 ok=30 133s run 2: PASS rc=0 ok=30 135s run 3: PASS rc=0 ok=30 118s run 4: PASS rc=0 ok=30 119s run 5: PASS rc=0 ok=30 124s == TARGET 651212b (fix) result: 5 of 5 passed

== TARGET 651212b (fix): tests/fm-pi-watch-extension.test.sh pinned to cpu 11 with 20 spinners, 5 runs (651212b, node v24.18.0)
run 1: PASS rc=0 ok=30 133s
run 2: PASS rc=0 ok=30 135s
run 3: PASS rc=0 ok=30 118s
run 4: PASS rc=0 ok=30 119s
run 5: PASS rc=0 ok=30 124s
== TARGET 651212b (fix) result: 5 of 5 passed
Evidence: Shard-4 order on 4 CPUs, base: 5 leaked workers, Pi suite fails 3/3 in the fleet-lock case

Source: Shard-4 order on 4 CPUs, base: 5 leaked workers, Pi suite fails 3/3 in the fleet-lock case

round 1: startup-network rc=0 | workers left when Pi suite starts=5, when it ends=7 | Pi suite rc=1 ok=18 18s not ok - OpenCode watch plugin must arm only when this session owns the fleet lock: expected exit 0, got 1 round 2: ... starts=5, ends=9 | Pi suite rc=1 (same case) round 3: ... starts=5, ends=6 | Pi suite rc=1 (same case) == BASE 2804ff7 (main) result: Pi suite passed 0 of 3 rounds

== BASE 2804ff7 (main): startup-network -> pi-watch-extension, pinned to cpus 12-15, 3 rounds
round 1: startup-network rc=0 | workers left when Pi suite starts=5, when it ends=7 | Pi suite rc=1 ok=18 18s
    not ok - OpenCode watch plugin must arm only when this session owns the fleet lock: expected exit 0, got 1
round 2: startup-network rc=0 | workers left when Pi suite starts=5, when it ends=9 | Pi suite rc=1 ok=18 17s
    not ok - OpenCode watch plugin must arm only when this session owns the fleet lock: expected exit 0, got 1
round 3: startup-network rc=0 | workers left when Pi suite starts=5, when it ends=6 | Pi suite rc=1 ok=18 18s
    not ok - OpenCode watch plugin must arm only when this session owns the fleet lock: expected exit 0, got 1
== BASE 2804ff7 (main) result: Pi suite passed 0 of 3 rounds
Evidence: Shard-4 order on 4 CPUs, target: no workers left behind, Pi suite passes 3/3

Source: Shard-4 order on 4 CPUs, target: no workers left behind, Pi suite passes 3/3

round 1: startup-network rc=0 | workers left when Pi suite starts=4, when it ends=0 | Pi suite rc=0 ok=30 11s round 2: startup-network rc=0 | workers left when Pi suite starts=4, when it ends=0 | Pi suite rc=0 ok=30 11s round 3: startup-network rc=0 | workers left when Pi suite starts=5, when it ends=0 | Pi suite rc=0 ok=30 10s == TARGET 651212b (fix) result: Pi suite passed 3 of 3 rounds

== TARGET 651212b (fix): startup-network -> pi-watch-extension, pinned to cpus 16-19, 3 rounds
round 1: startup-network rc=0 | workers left when Pi suite starts=4, when it ends=0 | Pi suite rc=0 ok=30 11s
round 2: startup-network rc=0 | workers left when Pi suite starts=4, when it ends=0 | Pi suite rc=0 ok=30 11s
round 3: startup-network rc=0 | workers left when Pi suite starts=5, when it ends=0 | Pi suite rc=0 ok=30 10s
== TARGET 651212b (fix) result: Pi suite passed 3 of 3 rounds
Evidence: Leaked startup-network workers, base vs target (process listing over 15 seconds)

Source: Leaked startup-network workers, base vs target (process listing over 15 seconds)

== BASE 2804ff7: tests/fm-startup-network.test.sh (base tests, base bin), then watch what it leaves behind
suite rc=0
t+0s: leftover workers=5
    2870563  4.0       01:06 bash /tmp/fm-startup-network-tests.Ke9UCJ/start-nonblocking/root/bin/fm-startup-network.sh run --locked 1 --lock-pid 2867484 
    2890031  4.8       00:43 bash /tmp/fm-startup-network-tests.Ke9UCJ/lock-changed/root/bin/fm-startup-network.sh run --locked 1 --lock-pid 2867484 --gen
    2895417  4.0       00:34 bash /tmp/fm-startup-network-tests.Ke9UCJ/single-flight/root/bin/fm-startup-network.sh run --locked 1 --lock-pid 2867484 --ge
    2909837  4.7       00:19 bash /tmp/fm-startup-network-tests.Ke9UCJ/owner-handoff/root/bin/fm-startup-network.sh run --locked 1 --lock-pid 2867483 --ge
    2914742  3.0       00:15 bash /tmp/fm-startup-network-tests.Ke9UCJ/sweep-lease/root/bin/fm-startup-network.sh run --locked 1 --lock-pid 2867484 --gene
t+5s: leftover workers=5
    2870563  4.2       01:11 bash /tmp/fm-startup-network-tests.Ke9UCJ/start-nonblocking/root/bin/fm-startup-network.sh run --locked 1 --lock-pid 2867484 
    2890031  5.1       00:48 bash /tmp/fm-startup-network-tests.Ke9UCJ/lock-changed/root/bin/fm-startup-network.sh run --locked 1 --lock-pid 2867484 --gen
    2895417  4.3       00:39 bash /tmp/fm-startup-network-tests.Ke9UCJ/single-flight/root/bin/fm-startup-network.sh run --locked 1 --lock-pid 2867484 --ge
    2909837  5.2       00:24 bash /tmp/fm-startup-network-tests.Ke9UCJ/owner-handoff/root/bin/fm-startup-network.sh run --locked 1 --lock-pid 2867483 --ge
    2914742  3.9       00:20 bash /tmp/fm-startup-network-tests.Ke9UCJ/sweep-lease/root/bin/fm-startup-network.sh run --locked 1 --lock-pid 2867484 --gene
t+15s: leftover workers=12
    2870563  4.9       01:21 bash /tmp/fm-startup-network-tests.Ke9UCJ/start-nonblocking/root/bin/fm-startup-network.sh run --locked 1 --lock-pid 2867484 
    2890031  5.8       00:58 bash /tmp/fm-startup-network-tests.Ke9UCJ/lock-changed/root/bin/fm-startup-network.sh run --locked 1 --lock-pid 2867484 --gen
    2895417  5.3       00:49 bash /tmp/fm-startup-network-tests.Ke9UCJ/single-flight/root/bin/fm-startup-network.sh run --locked 1 --lock-pid 2867484 --ge
    2909837  6.4       00:34 bash /tmp/fm-startup-network-tests.Ke9UCJ/owner-handoff/root/bin/fm-startup-network.sh run --locked 1 --lock-pid 2867483 --ge
    2914742  5.7       00:30 bash /tmp/fm-startup-network-tests.Ke9UCJ/sweep-lease/root/bin/fm-startup-network.sh run --locked 1 --lock-pid 2867484 --gene
-- killing the leaked base workers so they stop loading the host
after kill: leftover workers=0

== TARGET 651212b: same suite, same observation
suite rc=0
t+0s: leftover workers=4
    2967122  3.8       01:09 bash /tmp/fm-startup-network-tests.vWXYoh/start-nonblocking/root/bin/fm-startup-network.sh run --locked 1 --lock-pid 2966256 
    2990485  4.4       00:40 bash /tmp/fm-startup-network-tests.vWXYoh/lock-changed/root/bin/fm-startup-network.sh run --locked 1 --lock-pid 2966256 --gen
    3021969  4.1       00:17 bash /tmp/fm-startup-network-tests.vWXYoh/owner-handoff/root/bin/fm-startup-network.sh run --locked 1 --lock-pid 2966255 --ge
t+5s: leftover workers=0
t+15s: leftover workers=0
Evidence: New regression tests fail on the previous product code and pass on the fix

Source: New regression tests fail on the previous product code and pass on the fix

== New regression tests (from 651212b) run against the PREVIOUS product code (bin/ at base 2804ff7)
   (tree = git archive of 2804ff7 with only the two test files replaced; tests/lib.sh is byte-identical)

-- tests/fm-watcher-lock.test.sh            rc=1
not ok - acquiring or waiting for a lock in a removed directory never returned
   (fm_run_timed's 10s bound converted the old unbounded .steal recursion / endless wait into a failure)

-- tests/fm-startup-network.test.sh         rc=1
not ok - the worker kept waiting on its locks after its home was removed

== Same tests against the fixed product code (651212b)
-- tests/fm-watcher-lock.test.sh            rc=0
ok - a lock whose directory was removed is refused, and waited for, without a steal chain
ok - a lock released while an acquirer was losing to it is taken with one bounded retry
-- tests/fm-startup-network.test.sh         rc=0
ok - fm-startup-network: a worker whose home is removed exits instead of waiting forever
ok - fm-startup-network: a worker delivering its wake exits when its home is removed
ok - fm-startup-network: a fleet-lock takeover that loses its home mid-claim fails closed
Evidence: Target: 8 concurrent copies over 3 rounds passed 24/24; serial passed 5/5

Source: Target: 8 concurrent copies over 3 rounds passed 24/24; serial passed 5/5

round 1: PASS PASS PASS PASS PASS PASS PASS PASS round 2: PASS PASS PASS PASS PASS PASS PASS PASS round 3: PASS PASS PASS PASS PASS PASS PASS PASS == concurrent result: 24 of 24 passed == serial result: 5 of 5 passed (about 10s each)

== TARGET 651212b: 8 concurrent copies of tests/fm-pi-watch-extension.test.sh x 3 rounds, all pinned to cpus 16-19
round 1: PASS PASS PASS PASS PASS PASS PASS PASS
round 2: PASS PASS PASS PASS PASS PASS PASS PASS
round 3: PASS PASS PASS PASS PASS PASS PASS PASS
== concurrent result: 24 of 24 passed

== TARGET 651212b: serial, unloaded, 5 runs
run 1: PASS ok=30 10s
run 2: PASS ok=30 11s
run 3: PASS ok=30 10s
run 4: PASS ok=30 11s
run 5: PASS ok=30 10s
== serial result: 5 of 5 passed
Evidence: Timing budgets identical to main; only the 120ms sleep removed; no plugin files changed

Source: Timing budgets identical to main; only the 120ms sleep removed; no plugin files changed

== Timing budgets configured by tests/fm-pi-watch-extension.test.sh: base 2804ff7 vs target 651212b
identical (count  assignment):
      3 FM_OPENCODE_ARM_READY_TIMEOUT_MS=250
      3 FM_PI_ARM_READY_TIMEOUT_MS=250
      4 FM_WATCH_ARM_RETIRE_TIMEOUT_MS=20
      1 FM_WATCH_REARM_RETRY_BASE_MS=10000
     13 FM_WATCH_REARM_RETRY_BASE_MS=5
     13 FM_WATCH_REARM_RETRY_LIMIT=2
     13 FM_WATCH_REARM_RETRY_MAX_MS=10
      1 FM_WATCH_REARM_RETRY_MAX_MS=10000

== Real-time waits removed/added in the suite (setTimeout/sleep literals), base -> target
+    await sleep(10);
-  await new Promise((resolve) => setTimeout(resolve, 10));
-await new Promise((resolve) => setTimeout(resolve, 100));
+await clock.sleep(100);
-  await new Promise((resolve) => setTimeout(resolve, 10));
-await new Promise((resolve) => setTimeout(resolve, 80));
+await clock.sleep(80);
-    await new Promise((resolve) => setTimeout(resolve, 10));
-  await new Promise((resolve) => setTimeout(resolve, 10));
-await new Promise((resolve) => setTimeout(resolve, 80));
+await clock.sleep(80);
-await new Promise((resolve) => setTimeout(resolve, 120));
-  await new Promise((resolve) => setTimeout(resolve, 10));
-await new Promise((resolve) => setTimeout(resolve, 100));
+await clock.sleep(100);
-  await new Promise((resolve) => setTimeout(resolve, 10));
-await new Promise((resolve) => setTimeout(resolve, 80));
+await clock.sleep(80);
-    await new Promise((resolve) => setTimeout(resolve, 10));
-  await new Promise((resolve) => setTimeout(resolve, 10));
-await new Promise((resolve) => setTimeout(resolve, 80));
+await clock.sleep(80);

== Plugin / extension files changed between base and target:
    none
Evidence: Load harness used for the pinned-CPU spinner runs

Source: Load harness used for the pinned-CPU spinner runs

#!/usr/bin/env bash
# usage: load-harness.sh <label> <repo-root> <cpu> <spinners> <runs> <suite>
# Pins <spinners> busy loops and the suite to one CPU, runs the suite <runs> times.
set -u
label=$1 root=$2 cpu=$3 spinners=$4 runs=$5 suite=$6
unset FM_HOME
pids=()
for _ in $(seq "$spinners"); do
  taskset -c "$cpu" bash -c 'while :; do :; done' &
  pids+=($!)
done
trap 'kill "${pids[@]}" 2>/dev/null' EXIT
pass=0
printf '== %s: %s pinned to cpu %s with %s spinners, %s runs (%s, node %s)\n' \
  "$label" "$suite" "$cpu" "$spinners" "$runs" "$(git -C "$root" rev-parse --short HEAD 2>/dev/null || basename "$root")" "$(node --version)"
for i in $(seq "$runs"); do
  start=$(date +%s)
  out=$(cd "$root" && taskset -c "$cpu" bash "$suite" 2>&1); rc=$?
  dur=$(( $(date +%s) - start ))
  oks=$(printf '%s\n' "$out" | grep -c '^ok ')
  if [ "$rc" -eq 0 ]; then
    pass=$((pass + 1))
    printf 'run %s: PASS rc=0 ok=%s %ss\n' "$i" "$oks" "$dur"
  else
    printf 'run %s: FAIL rc=%s ok=%s %ss\n' "$i" "$rc" "$oks" "$dur"
    printf '%s\n' "$out" | grep -v '^ok ' | head -8 | sed 's/^/    /'
  fi
done
printf '== %s result: %s of %s passed\n' "$label" "$pass" "$runs"
Evidence: Shard-order harness (startup-network, then the Pi suite, on 4 CPUs)

Source: Shard-order harness (startup-network, then the Pi suite, on 4 CPUs)

#!/usr/bin/env bash
# usage: shard-order-harness.sh <label> <repo-root> <cpu-list> <rounds>
# Reproduces the CI shard-4 neighbourhood: fm-startup-network.test.sh, then
# fm-pi-watch-extension.test.sh straight after it, all pinned to 4 CPUs.
set -u
label=$1 root=$2 cpus=$3 rounds=$4
unset FM_HOME
export TMPDIR; TMPDIR=$(mktemp -d /tmp/fm-shard-order.XXXXXX)
leftover() { pgrep -f "$TMPDIR/fm-startup-network-tests\." | wc -l; }
pass=0
printf '== %s: startup-network -> pi-watch-extension, pinned to cpus %s, %s rounds\n' "$label" "$cpus" "$rounds"
for i in $(seq "$rounds"); do
  (cd "$root" && taskset -c "$cpus" bash tests/fm-startup-network.test.sh >/dev/null 2>&1); rc1=$?
  left_before=$(leftover)
  start=$(date +%s)
  out=$(cd "$root" && taskset -c "$cpus" bash tests/fm-pi-watch-extension.test.sh 2>&1); rc2=$?
  dur=$(( $(date +%s) - start ))
  left_after=$(leftover)
  [ "$rc2" -eq 0 ] && pass=$((pass + 1))
  printf 'round %s: startup-network rc=%s | workers left when Pi suite starts=%s, when it ends=%s | Pi suite rc=%s ok=%s %ss\n' \
    "$i" "$rc1" "$left_before" "$left_after" "$rc2" "$(printf '%s\n' "$out" | grep -c '^ok ')" "$dur"
  [ "$rc2" -eq 0 ] || printf '%s\n' "$out" | grep -v '^ok ' | head -4 | sed 's/^/    /'
  pkill -KILL -f "$TMPDIR/fm-startup-network-tests\." 2>/dev/null
done
printf '== %s result: Pi suite passed %s of %s rounds\n' "$label" "$pass" "$rounds"
rm -rf "$TMPDIR"
- Outcome: ⚠️ 1 info across 1 run (15m47s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ tests/fm-pi-watch-extension.test.sh:19 - The new EXIT reaper can SIGKILL unrelated processes if the temp root was never created. It kills every process whose args match "$TMP_ROOT/", and TMP_ROOT is never checked for being non-empty. If fm_test_tmproot fails (mktemp -d error, full or unwritable TMPDIR, registry write failure), line 8 leaves TMP_ROOT empty. The script runs under set -u only, so it carries on and still installs the trap. At exit the pattern becomes "/", which matches almost every process on the host. On a dev box that includes the user's shells, editors and the shared no-mistakes daemon, which the intent says must never be stopped. Fix: exit right after line 8 when TMP_ROOT is empty ([ -n &#34;$TMP_ROOT&#34; ] || exit 1), and/or return early from cleanup_arm_fixtures when TMP_ROOT is empty or is not an existing directory, before the ps sweep.
  • ℹ️ bin/fm-wake-lib.sh:740 - The new absent-lock refusal changes what happens when a lock is released between fm_lock_try_create failing and the existence check. Before this change, that acquirer fell through the stale path and took the lock: an empty pid is not alive, fm_path_age returned 999999, so it stole and recreated the lock. It now returns 1 with FM_LOCK_HELD_PID empty. Waiting callers absorb this on their next 100ms retry. One-shot callers treat it as 'held'. For example, bin/fm-watch.sh:745 prints 'watcher: already running' and exits 0 even though the previous watcher has just released the lock. A caller that checked a moment earlier, while the holder was still alive, would have got the same answer, so this is not a new kind of failure. The window is slightly wider than before. No action needed. If it ever matters, one fm_lock_try_create retry when the lock is absent but its parent directory exists would restore the old behavior without bringing back the recursion.
  • ℹ️ bin/fm-wake-lib.sh:845 - fm_lock_acquire_wait can now return 1, and several callers that run under set -u only ignore that status and carry on as if they held the lock. These are bin/fm-lock.sh:76 (sets CLAIM_LOCK_HELD=1), bin/fm-wake-drain.sh:217 and :230 (set DRAIN_LOCK_HELD=true), bin/fm-watch.sh:524, bin/fm-afk-return.sh:232, and bin/fm-x-lib.sh:942, :969 and :988. I checked each one. The next step touches only the removed directory and fails closed (write, mktemp or [ -f meta ] errors), and fm_lock_release checks the pid, so nothing is corrupted. This matches the author's audit. The one remaining exposure needs the state dir to be recreated between the failed wait and the next write. fm-lock.sh's own mkdir -p &#34;$STATE&#34; in a second session could do that, and two sessions could then both write and verify state/.lock without the claim mutex. That is far-fetched. If the fleet lock should fail closed literally, the fix is a one-line || exit 1 at bin/fm-lock.sh:76. I am noting this as a tradeoff, not a blocker.

🔧 Fix: Guard test reaper, retry released locks, fail-closed claim mutex
1 info still open:

  • ℹ️ tests/fm-pi-watch-extension.test.sh:20 - The fix round's new [ -d &#34;$TMP_ROOT&#34; ] guard means the process sweep is skipped when the suite is interrupted by INT or TERM. tests/lib.sh installs trap &#39;fm_test_cleanup; exit 130&#39; INT and trap &#39;fm_test_cleanup; exit 143&#39; TERM, and this suite overrides only EXIT. On a signal, fm_test_cleanup removes TMP_ROOT first. The EXIT trap then runs cleanup_arm_fixtures, finds no directory, and skips the sweep. Before the fix round the sweep ran on this path. Fixtures that ignore signals are then left running after a Ctrl-C or a TERM: line 593 uses trap &#39;&#39; TERM INT and loops waiting for a release file that never arrives. The ordinary fail/exit path is not affected. Fix: keep the guard and have the suite install its own signal traps after line 29, so the sweep runs while the root still exists: trap &#39;cleanup_arm_fixtures; exit 130&#39; INT and trap &#39;cleanup_arm_fixtures; exit 143&#39; TERM.
⚠️ **Test** - 1 info
  • ℹ️ The intent says the suite works on Node 22.18 and 24. Only Node v24.18.0 is installed on this host, and installing another runtime is outside the workspace boundary, so the Node 22 claim was not re-verified here. Remote CI covers it.
  • taskset -c 5 bash tests/fm-pi-watch-extension.test.sh on a git archive of base 2804ff7, with 20 busy loops pinned to the same CPU, 3 runs. Failed 3/3 with not ok - Pi must deliver the actionable wake after bounded hung-successor recovery: expected exit 0, got 1, which is CI's failure.
  • taskset -c 11 bash tests/fm-pi-watch-extension.test.sh at target 651212b under the same 20-spinner load, 5 runs. Passed 5/5, with 30 ok lines each.
  • Shard-4 order pinned to 4 CPUs: tests/fm-startup-network.test.sh, then tests/fm-pi-watch-extension.test.sh, 3 rounds each. On base, 5 leaked workers were still running when the Pi suite started and it failed 3/3 in the OpenCode fleet-lock case. On target, no workers remained when the Pi suite ended and it passed 3/3.
  • Leaked-worker check after tests/fm-startup-network.test.sh. On base, 5 detached workers were still alive and using more CPU 15 seconds later, until I killed them. On target, no workers remained within 5 seconds.
  • 8 concurrent copies of tests/fm-pi-watch-extension.test.sh pinned to 4 CPUs, 3 rounds, at target. Passed 24/24.
  • bash tests/fm-pi-watch-extension.test.sh serial and unloaded at target, 5 runs. Passed 5/5 in about 10 seconds each.
  • bash tests/fm-watcher-lock.test.sh and bash tests/fm-startup-network.test.sh at target. Both passed, including the 5 new regression cases.
  • The target's tests/fm-watcher-lock.test.sh and tests/fm-startup-network.test.sh run against base bin/. Both failed: acquiring or waiting for a lock in a removed directory never returned, and the worker kept waiting on its locks after its home was removed.
  • Compared the FM_*_MS and RETRY_LIMIT assignments in the Pi suite between base and target. They are identical. The only real-time wait removed is the 120ms sleep, and git diff --name-only over .pi and .opencode lists no files.
  • FM_HOME was unset for every run. The no-mistakes daemon was not touched. The temp base trees and the leaked base workers I created were removed, and the worktree is clean.
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

fm_lock_try_acquire read a lock it could not create as stale even when no
lock existed at all, then tried to steal it through "$lock.steal", which
failed the same way one level deeper, without bound. Any caller whose lock
directory had been removed recursed like that, forking at every level and
burning a CPU core for minutes. It now refuses an absent lock outright;
recovering an existing stale lock is unchanged.

fm_lock_acquire_wait still waited forever for a lock whose directory was
gone, and now fails instead. Most callers already handle a failed wait, and
the rest act only on the directory that no longer exists. fm_wake_append
stops at that failure, so the deferred startup-network worker, which
outlives the session start that launched it and waits on its publication
lock and the wake queue, returns once its home is removed.

tests/fm-startup-network.test.sh left five such workers behind on every run,
once its fixture homes were removed at exit. On a 4-vCPU CI runner they
slowed every later suite in the shard two to three times, which turned
load-sensitive cases in tests/fm-pi-watch-extension.test.sh into a
deterministic failure whenever a shard ran that suite after this one.

tests/fm-watcher-lock.test.sh pins both lock calls refusing a removed
directory without a steal chain, and tests/fm-startup-network.test.sh pins a
worker exiting when its home is removed while it waits on its publication
lock or while it delivers its wake; each fails on the previous code.
Several watch-arm recovery cases raced their own timing. A successor
launched through a login shell could still be starting when a 250ms
readiness budget lapsed, so it was retired before it recorded itself or
installed the TERM trap its case depends on. A 120ms sleep stood in for the
OpenCode plugin's unowned launch settling, so a slow launch answered the
next idle event with the lock it had already read and never armed. And the
stub turn-end guards exited without reading the payload the plugin writes,
which failed that write with an unhandled EPIPE on a loaded host.

The recovery cases now run on a mock clock: each readiness, retirement, or
retry budget lapses only when the case elapses it, and a case does so only
after the child that budget measures has visibly reached the state under
test. The lock case waits on the plugin's in-flight launch through its
shared coordinator, and the stub guards read their payload as
bin/fm-turnend-guard.sh does. No budget value changes.

A case that fails leaves its arm fixtures looping on release files that
never appear, so the suite now reaps every process still running from its
own temp root when it exits, instead of leaving that load to later suites.

With 20 CPU spinners on the test's CPU, the previous recovery cases passed
0 or 1 of 3 runs each, failing as CI did ("expected one successor plus two
retries, got 2"), while the new ones pass every run.
@SchokoShake
SchokoShake merged commit 938dff7 into main Sep 17, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant