feat(radio): a PTT guard releases only the key it took (#1263) - #1289
Merged
Conversation
`unkey()` released the hardware and took the deadline regardless of who keyed, so a guard that outlived its key dropped the transmitter of whoever keyed next, and that holder's own release became a NotKeyed no-op. Present under every nesting policy, so the token comes first and the policy second. The owner is the GUARD, settled across two review passes. Not a thread id: ARDOP's PTT TRUE and PTT FALSE run on different spawn_blocking pool threads, so an operator could not release their own key, and the daemon's task migrates across tokio workers between awaits. Not a caller-supplied enum: that is a label any site can claim, the same "only as good as every call site" hole the first proposal had. Holding a live guard IS ownership; the generation counter IS the identity. My first proposal was rejected. I proposed that a nested keyed() refuse, which (1) names a thin wrapper the manual paths never pass through, so the rule would have excluded every path it was written for, and (2) would have caused a §97.119 miss — verified in code, not argued: mark_identified is called "Advance regardless of PTT success" and clears both last_id_ms and tx_since_id, so a refused station ID skips a whole interval. The review's worst case — a stale owner record deferring the ID forever with the rig unkeyed — is avoided by construction: there is deliberately no separate owner field. Ownership is "my generation is live AND asserted_at.is_some()", so every existing release path already ends it. The watchdog also bumps the generation, stating the invariant rather than relying on it. The watchdog stays generation-blind, which is the safety property: a guard sets released = true and never retries, so on UnkeyOutcome::Failed only the 100 ms watchdog can clear a stuck rig. unkey_owned checks and acts under ONE lock acquisition — check-then-release across two would race the one thread that can preempt a live key at any instant. Results: 4/4 new tests; two FAIL against the restored unconditional release with "a stale guard released a key it did not take", while both controls keep passing. Restored by sha256sum. Radio 45 + 11, daemon lib 136, ARDOP 7 + 24, clippy clean. Deferred to PR-2: AlreadyKeyed, the three-way KeyedTxError, ID-outranks-manual, deferral scoped to AlreadyKeyed only (blanket deferral would hammer a faulted rig at 20 Hz for 180 s — "advance regardless" is load-bearing), manual guard slots, PttRelease force semantics, the panel. Implements: REQ-PTT-01 Review: docs/dev/reviews/2026-09-06-1263-ptt-nesting.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188ATCj6DZ9aRVQ2vSirua6
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part 1 of #1263 — the ownership token. The policy (refuse / defer / force) is PR-2.
The defect
unkey()released the hardware andtake()d the deadline regardless of who keyed. So a guardthat outlived its key — a hung transmit the watchdog force-released — dropped the transmitter of
whoever keyed next, and that holder's own release then returned
NotKeyed. This is present underevery nesting policy, which is why the token lands first and the policy second.
The owner is the guard
Settled across two review passes (
docs/dev/reviews/2026-09-06-1263-ptt-nesting.md).PTT TRUEandPTT FALSEeach run on a differentspawn_blockingpool thread, so an operator could not release their own key; and the daemon's task migrates across
tokio workers between awaits, so one logical owner has many thread ids.
every call site" hole my first proposal had, in a new shape.
"refuse a different owner" later collapses to "refuse any key while a live guard exists", and "the
same owner emitting under its own hold" means emit without taking a second guard.
My first proposal was rejected, and both reasons matter
keyed()refuse.keyed()is a thin wrapper; themanual paths — ARDOP's
key(None), the daemon'shw_assert+arm— never pass through it, sothe rule would have excluded every path it was written for.
id_timer.mark_identified(now_ms)is called "Advance regardless of PTT success" and setslast_id_ms = nowandtx_since_id = false. A refused station ID skips a whole interval. Afix for a UX defect would have created a regulatory one.
The review's worst case is avoided by construction
F1 was a stale owner record deferring the ID forever while the rig sits unkeyed. There is
deliberately no separate owner field: ownership is "my generation is live and
asserted_at.is_some()", so every existing release path already ends it. The watchdog additionallybumps the generation, which states the invariant positively rather than relying on it.
The watchdog stays generation-blind, and that is the safety property: a guard sets
released = trueand never retries, so onUnkeyOutcome::Failedonly the 100 ms watchdog can cleara stuck rig.
unkey_ownedchecks and acts under one lock acquisition — a check-then-releaseacross two would race the one thread that can preempt a live key at any instant.
Tests
release count, not just
is_keyed, so a no-op that still touched the rig would fail;guards released nothing);
is_liverequires an armed deadline, not merely a matching generation (F1's shape);release()is scoped exactly asDropis.Sabotage: restoring the unconditional release fails two of the four with
a stale guard released a key it did not take, while both controls keep passing — so the failure is attributable.Restored by
sha256sum.Deferred to PR-2, deliberately
AlreadyKeyed, the three-wayKeyedTxError, ID-outranks-manual at the acquisition point, anddeferral scoped to
AlreadyKeyedonly — the "advance regardless" comment is load-bearing, andblanket deferral would key/attempt at 20 Hz on a faulted rig's serial line for 180 s. Plus the manual
guard slots,
PttReleaseforce semantics, and the panel.Both before #1260, which is open and would otherwise be built on the
key/unkeysurface PR-2removes.
Review: docs/dev/reviews/2026-09-06-1263-ptt-nesting.md
🤖 Generated with Claude Code
https://claude.ai/code/session_0188ATCj6DZ9aRVQ2vSirua6