Skip to content

cleanup(brain): retire the legacy supervisor lock after every runnable checkout is bridge-aware #224

Description

@adrianwedd

Follow-up to #221 / #223. This is a removal gate, not a cleanup ticket: the work is
the proof, and the deletion is the last step of it. Nothing here should be actioned
because the code "looks like dead migration cruft".

Acceptance invariant

After migration, supervisor authority is keyed solely to the tmux socket namespace.
Checkout location must have zero effect on whether two supervisors contend.

Why the bridge exists (do not re-derive this from the comments)

The bridge exists solely to prevent a mixed-version deployment from creating the exact
two-supervisor window #221 fixed. Removing it early is a regression in authority
containment, not cleanup debt.

#223 re-keyed the guard from brain_root()/.supervisor.lock (checkout-relative) to
<socket>.supervisor.lock (host-global). A pre-bridge binary knows only the first
key. So for as long as one can be started, a post-bridge supervisor must hold both
locks, or the two versions guard different inodes and both start.

The cost, which is the thing that gets removed here: while the bridge holds, two
supervisors on different sockets in the same checkout also contend, because both
must take that checkout's legacy lock. The migration-era namespace is the pair
(socket, checkout).

The gate — every item must be proved before touching _BRIDGE_HOLDS_LEGACY_LOCK

Audit run 2026-08-19, immediately after #223 merged as 663b378d. Two items fail
today.
Re-run all of it at removal time; do not trust this table's age.

G1 — Enumerate every checkout on the host that can plausibly start bin/px-brain

find /home/pi -maxdepth 3 -name px-brain -type f
Checkout HEAD Branch Contains 663b378d legacy_supervisor_lock_path refs
/home/pi/picar-x-hacking 663b378d master YES 3
/home/pi/px-brain-wt efe22d42 e2e/brain-holder-verify NO 0

❌ FAILS. /home/pi/px-brain-wt is a runnable pre-bridge checkout. Its
brain_daemon.py contains no reference to the socket lock at all, so a supervisor
started from it guards only state/brain/.supervisor.lock under that worktree. This is
precisely the binary the bridge exists to lose to.

Resolution is either: rebase/merge that worktree past 663b378d, or delete it if the
e2e/brain-holder-verify work is finished. Prove it per checkout, not per branch
a merged branch whose worktree still sits on an old commit is still a runnable
pre-bridge binary.

G2 — Record each checkout's commit and prove it contains the bridge

for d in $(find /home/pi -maxdepth 3 -name px-brain -type f -printf '%h\n' | xargs -n1 dirname); do
  git -C "$d" merge-base --is-ancestor 663b378d HEAD && echo "$d OK" || echo "$d PRE-BRIDGE"
done

Ancestry, not a grep for the function name — a checkout can carry the symbol from a
partial cherry-pick without carrying the dual acquire.

G3 — Inspect the active unit's WorkingDirectory and executable path

systemctl cat px-brain.service | grep -E '^(WorkingDirectory|ExecStart)='
WorkingDirectory=/home/pi/picar-x-hacking
ExecStart=/home/pi/picar-x-hacking/bin/px-brain

✅ PASSES. One unit, pointing at the bridge-aware checkout.

G4 — Confirm no unit, cron, wrapper, shell script or other launcher points at a pre-bridge checkout

ExecStart is not the only way in. Sweep at minimum:

systemctl list-units --all | grep -i brain
grep -rl 'px-brain' /etc/systemd/system /etc/cron* /home/pi/*/bin 2>/dev/null
crontab -l; sudo crontab -l
ssh M5.local 'crontab -l'   # M5 pushes to this host; check it cannot invoke px-brain

Not yet swept — the M5 cron leg in particular has not been checked.

G5 — Confirm the running production supervisor is bridge-aware

Not "the unit points at a bridge-aware checkout" (G3) — the live process. A
bridge-aware supervisor holds two locks; a pre-bridge one holds only the legacy lock.
That is directly observable:

ls -la /tmp/tmux-1000/*.supervisor.lock          # socket lock present?
sudo lsof "$(git rev-parse --show-toplevel)/state/brain/.supervisor.lock"

Observed 2026-08-19:

socket lock:  ABSENT   (/tmp/tmux-1000/ holds only the px-mind socket itself)
legacy lock:  python3  PID 740  pi  3uW  .../state/brain/.supervisor.lock
PID 740 started 12:16:19 AEST = 02:16 UTC; #223 merged 06:18 UTC

❌ FAILS. The running supervisor predates the merge and holds only the legacy lock.
It becomes bridge-aware on systemctl restart px-brain and not before. Until then the
host is in the mixed state the bridge is written for — which is the bridge working, not
a problem to fix by deleting it.

G6 — Only then, remove

In one commit:

  • _BRIDGE_HOLDS_LEGACY_LOCK and the if not ... branch in acquire_supervisor_lock()
  • legacy_supervisor_lock_path()
  • the second acquire, its failure path (the _drop_lock(fd)-then-refuse branch), and
    _legacy_fd throughout, including in release_supervisor_lock()
  • TestMigrationBridge in tests/test_supervisor_lock_scope.py
  • TestContention::test_after_removal_the_socket_alone_is_the_namespace (case 4 —
    it exists to describe this end state and is redundant once reached)
  • the migration paragraphs in CLAUDE.md and acceptance criterion 2 of
    docs/specs/2026-08-19-supervisor-lock-scope-migration-design.md

Post-removal test expectations

TestContention is written so that removal shows up as a specific, single flip
if anything else moves, the removal broke containment:

Case Test Before After
1 test_two_checkouts_on_one_socket_cannot_both_acquire contend contend (unchanged — this is #221's fix)
2 test_different_sockets_and_different_state_roots_coexist coexist coexist (unchanged)
3 test_different_sockets_in_one_checkout_contend_during_migration contend flips to coexist — rewrite the assertion and drop "during migration" from the name
4 test_after_removal_the_socket_alone_is_the_namespace passes under simulation deleted

Case 1 staying red-for-a-second-supervisor is the invariant. A removal that also
relaxes case 1 has re-opened #221.

Notes for whoever picks this up

  • _BRIDGE_HOLDS_LEGACY_LOCK is deliberately not env-readable. Do not add an
    override to "test removal in production" — flipping it live is exactly the two-
    supervisor window. Case 4 simulates the end state in-process; that is the supported
    way to see it.
  • Re-run the whole audit at removal time. G1 and G5 are both time-sensitive: a new
    worktree or an un-restarted daemon re-opens them silently.
  • Per CLAUDE.md, do not run the full suite on SPARK — CI is the gate.

Refs #221, #223.

Metadata

Metadata

Assignees

No one assigned

    Labels

    reliabilityReliability and error handlingsecuritySecurity-related issues

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions