fix(icom): harden session lifecycle and IC-9700 CI-V recovery - #5145
Conversation
There was a problem hiding this comment.
Issue fit
Partially. The lifecycle half of #5119 lands well: reset() now returns per-request terminal dispositions, waiters resolve as completed/timed-out/failed/cancelled, the session-generation gate sits ahead of every scope/slice/PTT/meter publication, and there are deterministic tests for cancellation, stale generations, partial login and app shutdown. Two acceptance criteria are not met by this diff: the VOX capture/restore criterion (explicitly deferred — fine, but the issue box stays unchecked), and "Poll inventory and cadence are documented before/after" is satisfied by tests rather than documentation (acceptable). The IC-9700 recovery half is where the problems are — see Blockers 1 and 2.
All findings below are reasoned from the head checkout at /tmp/aetherclaude/pr-5145; I have no build and ran nothing.
Scope
| File / group | What it changes | Claimed? | Verdict |
|---|---|---|---|
IcomCivBackend.{cpp,h} |
generation gate, terminateScheduler, waiter outcomes, recovery state machine |
yes | in scope |
IcomCivScheduler.{cpp,h} |
reset(TerminalOutcome) -> ResetResult; dispatch policy untouched (verified: takeNext/observe/priorityFor unchanged) |
yes | in scope |
IcomProtocol.{cpp,h} |
new wire packet buildSerialRestart (magic 0x04) |
yes | needs maintainer decision — new outbound protocol verb, no cited source (Blocker 2) |
IcomSession.{cpp,h} |
reopenCivPipe(); seq/sendTracked usage matches the existing open at IcomSession.cpp:593 |
yes | in scope |
schedulerDiagnostics() keys |
adds cancelledRequests, failedRequests, outcome, failed, cancelled to civ.scheduler.wait-idle results |
not in body | nit — automation-visible surface; timedOut semantics verified backward-compatible |
| tests (5 files) | new coverage + deletion of the deaf-radio stall block | deletion not disclosed | see Blocker 1 |
Nothing unrelated to Icom is touched. CHANGELOG.md correctly untouched.
Blockers
1. A probe reply slower than 350 ms can never verify recovery — so a radio that has recovered gets force-disconnected. (inline: IcomCivBackend.cpp:1431)
IcomCivScheduler::observe() calls expireRead(nowMs) on entry (IcomCivScheduler.cpp:242), and expireRead retires the in-flight read at kReadTimeoutMs = 350 (IcomCivScheduler.h:113, IcomCivScheduler.cpp:107). A matching reply arriving after that falls into the m_expired branch and, with no newer generation behind it, returns Observation::Unmatched — not Accepted (IcomCivScheduler.cpp:255-271). The file's own comment at IcomCivScheduler.cpp:114 spells this out: the same frame is "Stale at 349 ms and unmatched-therefore-authoritative at 351 ms," and the backend adopts Unmatched replies as truth everywhere else.
So: IC-9700 stalls, the 0x04 restart works, the radio answers cmdReadFrequency in 400 ms — an unremarkable latency for a pipe that just came back — observation == Unmatched, the gate at line 1431 fails, m_civRecoveryStartedAtMs stays set. Because the recovery block early-returns at the top of onLinkTick (IcomCivBackend.cpp:4396), no ordinary polling runs either, so nothing else can clear it. Three attempts later the backend tears down a session whose command plane is demonstrably alive. The feature converts a recovered link into a dropped one.
The test does not catch this because c.radio.setCivSilent(false) makes the fake radio answer in microseconds. Suggested fix: Accepted is not the right predicate — Stale is the only outcome that means "superseded by newer intent", so gate on observation != IcomCivScheduler::Observation::Stale.
2. The 0x04 recovery packet has no cited source and has never been sent to a radio. (inline: IcomProtocol.cpp:539)
IcomProtocol.h asserts "The IC-9700 distinguishes this data-start request (magic 0x04) from the initial open above (magic 0x05)" with no spec page, no capture, no public reference. I grepped the tree: no other occurrence of a serial-stream 0x04 magic exists (IcomSession.cpp:142 documents type 0x05 only). Principle IV's clean inputs are "public documentation, open-source references, behavior observed on the wire" — this cites none of the three, and the PR's own "Behavior verified on a real radio" box is unchecked.
The tests cannot close the gap, because the evidence is circular in two directions:
icom_protocol_test.cpp:326assertsrestart[0x15] == 0x04— the encoder asserted against itself.IcomFakeRadio.hwas taught by this PR that0x04means "restart", and the handler only timestamps it — it does not clearm_civSilentor resume CI-V. Recovery "succeeds" inicom_backend_test.cpponly because the test callssetCivSilent(false)by hand. The suite therefore proves the retry bookkeeping, not that the packet does anything.
This is a maintainer call, not a correctness claim on my part — but "we send a new, undocumented packet to real IC-9700 hardware on a stall" should be ratified with a provenance citation or a wire capture before merge, not inferred from a green fake-radio run.
3. Every Icom model now force-disconnects on 5 s of CI-V silence — and the PR describes this as pre-existing. (inline: IcomCivBackend.cpp:4533)
I diffed main: onLinkTick ends immediately after the CI-V STALL warning. There is no stall-triggered disconnect on main for any model. The PR body calls the non-IC-9700 path "the established full-session reconnect path" and "the existing full-session reconnect ... remains the fallback" — that path is introduced here. The diff also deletes the test block that pinned the old contract, including its comment:
"and isConnected() still reports true — the lie the detector exists to break"
Escalating a diagnostic warning to a teardown + RadioModel::onConnectionError reconnect timer (RadioModel.cpp:1431) is a defensible design, and I could not construct a false-positive: FB/FA acks, meter replies and the 1 s control poll all refresh m_lastInboundCivAtMs, so 5 s of true silence really does mean a dead command plane. But a change that turns "one log line" into "the operator's session drops" for IC-705 / IC-7300MK2 / IC-7760 needs to be stated as such in the body rather than framed as existing behavior, and the deleted assertion is a contract the maintainer should knowingly retire.
Nits (non-blocking)
IcomCivBackend.cpp:2562—terminateScheduleremits waiter results beforereset().extensionResultis a direct connection; a slot that re-enters and queues scheduler work has it silently wiped by the reset on the next line. The previous order was reset-then-service. Inline comment has a suggested reorder.adoptReportedCivAddress(IcomCivBackend.cpp:854,:910) now cancels pendingwait-idlewaiters during ordinary startup address adoption, not just teardown. Arguably right, but it is a new answer to an automation caller and isn't mentioned anywhere.icom_backend_test.cpp:2006—elapsed >= 900 && elapsed <= 1300is a wall-clock window around a 1000 msQTimerin a test that also runs three 9–12 s waits. A loaded CI runner can easily push one tick past 1300 ms. Consider asserting only the lower bound (cadence is not faster than configured) and dropping the upper.terminateSchedulerzeroesm_statsviareset()but leavesm_schedulerTimeoutsReportedat its old high-water mark, so the "CI-V read timeout" warning stays suppressed after a stall until the fresh counter climbs back past it.disconnectRadio()resets it; the stall path doesn't.~IcomCivBackend()emitsextensionResultfrom the destructor body. Legal (the object is still aQObject), and the new test relies on it — but a receiver that calls back into the backend lands in a half-destroyed object. Worth a comment at minimum.m_civRecoveryProbeSentis set at both queue sites and cleared only on verify/teardown; it is never observable asfalsewhilem_civRecoveryStartedAtMs > 0. Redundant state.
What I tried to break (and could not)
- The
0xA2model gate. GreppedIcomModels.cpp—0xA2is unique to IC-9700 in the table, andIcomModels.cpp:265already usescivAddress == 0xA2as the IC-9700 discriminator, so this matches house idiom. Brittle if a future model reuses the address, but correct today. buildSerialRestartbounds.framed(kLenOpenClose, …)yields a 0x16-byte packet, sopacket[0x15]is the last valid index; seq/sendTrackedusage is identical to the existing open atIcomSession.cpp:593.timedOutregression for existing wait-idle callers. Walked both non-terminal branches:idle→Completed→false,!idle→TimedOut→true. Byte-for-byte the old semantics.- Null derefs on the new paths.
m_modelis&unknownModel()from the constructor (IcomCivBackend.cpp:86), so them_model-less test fixture is safe through the scope branch;m_sessionis null-guarded in the recovery-candidate expression and non-null by theonLinkTickprologue. - Reentrancy of
disconnectRadio()fromonLinkTick(). The slot only emits after the call and touches no session state;!m_connectedgates subsequent ticks. - Retry coalescing. The comment claims
pumpCivretires the unanswered probe first — correct: 1000 ms retry interval > 350 ms read timeout, soexpireReadfires andenqueueissues a fresh generation. The old late reply then correctly classifiesStale. - The "another device cannot verify the probe" claim. True as written (
frame.fromcheck). Note an unsolicited Transceive broadcast from this radio can verify it — which is the correct outcome anyway, so not a finding. - "Healthy dispatch policy unchanged." Read
takeNext,observe,matches,priorityFor,enqueueagainstmain: onlyreset()changed. The claim holds.
Recommendation
Request changes. Blocker 1 is a concrete correctness defect that inverts the feature's purpose on exactly the hardware it targets, and it is masked by a fake radio that answers instantly — a one-line predicate change plus a test that delays the probe reply past 350 ms would settle it. Blockers 2 and 3 are maintainer decisions rather than my rulings: an unsourced new outbound packet, and a real behavior change for three shipping models presented as pre-existing. The lifecycle work (generations, terminal outcomes, teardown determinism) is genuinely good and I'd take it; if the IC-9700 recovery state machine needs another round, splitting it out would let the lifecycle half land now.
Nice work on the generation gate in particular — the positive controls in testStaleSessionFrameIsDropped are exactly what makes that test mean something.
🤖 aethersdr-agent · cost: $9.4503 · model: claude-opus-5
|
@jensenpat @aethersdr-agent — review fixes are now pushed in signed commit Alignment with Pat's compatibility requirement and the bot findings:
Validation after these changes:
Each inline review thread also has a specific response tied to its fix and regression evidence. |
Resolve scheduler work explicitly across resets, reject stale session publications, and bound IC-9700-only CI-V restart before full reconnect. Preserve healthy IC-705 and IC-7300MK2 polling, cadence, and scheduler dispatch behavior.
Accept authoritative late recovery replies, preserve warn-only stall behavior outside the IC-9700, and reset scheduler state before notifying re-entrant waiters. Add causal restart, delayed-reply, cross-model, and re-entrancy regression coverage. Document the public wfview and RigPlane protocol provenance for the model-gated data-start request.
Put the RS-BA1 data-pipe restart, scheduler termination, and bounded retry cadence behind the IC-9700 model profile introduced by aethersdr#5151. Preserve warn-only stalls, scheduler state, connection state, and link timer cadence for IC-705, IC-7300MK2, and unprofiled radios.
d1f2bc9 to
12f96e5
Compare
jensenpat
left a comment
There was a problem hiding this comment.
Reviewed exact head 12f96e5 against current main after the #5151 capability-profile rebase.
The IC-9700 RS-BA1 data-pipe restart, scheduler termination, and bounded 3-attempt/1-second retry policy are now one CivDataRestart capability. IC-705, IC-7300MK2, and unprofiled models have no recovery facet; regression coverage pins no 0x04 command, no recovery state, no scheduler reset/failure, continued connection, and unchanged link-timer cadence. Healthy startup/poll inventory and shared scheduler dispatch constants remain unchanged.
All required CI checks pass, all commits are GitHub-verified, review threads are resolved, and the exact-head synthetic merge succeeds. No new live-radio run was performed on this rebased head; RF/runtime evidence remains the contributor's published IC-9700 stall/recovery context plus deterministic fake-radio coverage.
Summary
Fixes #5119.
This PR hardens the network-Icom command-plane lifecycle without changing healthy-session dispatch policy. It gives queued/in-flight CI-V work and wait-idle callers explicit terminal outcomes during reset, rejects frames from disconnected or superseded session generations before any scope/slice/transmit/meter publication, makes partial and ordinary session teardown deterministic, and adds bounded IC-9700-only serial-stream recovery after a confirmed CI-V stall while RS-BA1 transport remains alive. If the targeted IC-9700 recovery does not verify after three one-second attempts, that IC-9700 session is replaced. IC-705, IC-7300MK2, and unknown Icom models retain main's existing warn-only stall behavior.
Review requested from @jensenpat and @aethersdr-agent. I attempted to claim #5119 before submission as required by AGENTS.md, but GitHub rejected
@meassignment for this contributor account withReplaceActorsForAssignable; the issue was unassigned and no competing claim was present.Maintainer and bot feedback alignment
@aethersdr-agent: scheduler work and waiters were stranded or falsely reported idle
IcomCivScheduler::reset()now returns every queued/in-flight request with an explicit terminal result instead of silently discarding it.@aethersdr-agent: late frames could publish after disconnect
onCivFrame()rejects disconnected or mismatched generations at entry, before hardware-scope, slice, PTT, or meter decoding can emit state.@aethersdr-agent: teardown, partial login, and fake-radio coverage
@aethersdr-agent VOX and polling observations, as narrowed by Pat's approval
@jensenpat: freeze the IC-705 / IC-7300MK2 scheduler contract
IcomCivScheduler; emergency unkey ordering is preserved.@jensenpat: lifecycle, state, and transmit boundaries
Pat's Discord concern: do not disturb known-good IC-705 / IC-7300MK2 timers
The observed failure was specifically a 5–6 second loss of CI-V replies on the IC-9700 while RS-BA1 packet counters continued and the scheduler queue grew. This PR does not infer that every Icom needs different scheduling. The serial data-stream restart (
0x04) is gated to CI-V address0xA2(IC-9700), and explicit IC-705/IC-7300MK2 tests prove those models never send it. Shared healthy scheduling remains unchanged.Public protocol provenance for the
0x04CI-V data-start requestIcom's public RS-BA1 documentation confirms that the control, serial, and audio transports use UDP ports 50001, 50002, and 50003, but it does not publish the proprietary OpenClose packet layout or assign a meaning to byte
0x04. This PR therefore does not describe0x04as vendor-documented. Its clean-room provenance is the following public implementation and operational evidence:0x04as data start and0x00as close. Its CI-V watchdog resends the same data-start request after two seconds without CI-V data and stops when valid CI-V data resumes. Git history traces this behavior to wfview's public May 2022 transport implementation.0x01C0at offset0x10,0x04at offset0x15for open/start, and0x00for close. Its published reliability semantics document that its two-second CI-V watchdog resends OpenClose to restart the stream.RigPlane explicitly credits wfview for the watchdog behavior, so it is a second public implementation and interoperability reference, not a claim of a second independent protocol discovery. Together, these sources establish an open-source precedent plus physical IC-9700 operational evidence. They do not establish that Icom publicly documented the OpenClose field.
Independent review follow-ups
Stale. A reply arriving after the 350 ms matching window isUnmatchedbut remains radio-authoritative; a deterministic 500 ms regression test covers that exact case.0x04data-start packet, so recovery is now causally exercised rather than enabled manually by the test.0x04.civ.scheduler.wait-idlenow exposes the automation-visible keysoutcome,failed,cancelled,cancelledRequests, andfailedRequests; the existingtimedOutkey retains its prior semantics. Startup CI-V-address adoption deliberately terminates pending waiters because it replaces the scheduler's destination/generation rather than reporting that pre-adoption work as idle.0x04stream restart is IC-9700-only.Scope boundaries and residual risk
src/core/backends/icom/and Icom test sources change.Staleobservations constrain acceptance. An unsolicited frequency frame from that selected radio also demonstrates that its command plane is alive; another device on a shared bus cannot verify recovery.Constitution principle honored
Principle XI — Fixes Are Demonstrated. Each reported lifecycle failure has deterministic regression evidence, including cancellation outcomes, stale-generation suppression, partial teardown, transport-alive/CI-V-silent recovery, bounded fallback, and unchanged healthy IC-705/IC-7300MK2 traffic.
Principle II — Radio-authoritative live state. A disconnected or superseded session can no longer publish stale slice, transmit, meter, or scope state as current.
Principle IX — Evidence Over Assertion. The compatibility claim is backed by exact startup/steady-state wire inventories and explicit non-IC-9700 recovery tests rather than model-name assumptions alone.
Test plan
cmake --build build)icom_civ_scheduler_testicom_protocol_testicom_session_testicom_backend_testcwx_panel_testafter rebasing Pat's fix(gui): prevent Icom CWK updates from stealing VFO focus #5134 fixgit diff --checktools/check_test_registration.py --stricttools/check_engine_boundary.py --strict(no blockers; existing baseline warnings only)Checklist
docs/COMMIT-SIGNING.md)AppSettingscalls — no settings changesMeterSmoother— not applicable; no meter UI changesCHANGELOG.mdis intentionally untouched\n\n### Maintainer adaptation after Icom: add evidence-backed model capability profiles #5151\n\nSuperseding the earlier address-gate wording above: this branch is rebased onto current main and the RS-BA1 data-pipe restart, scheduler termination, and bounded retry cadence are now gated by the authoritative IC-9700 CivDataRestart capability profile from Icom: add evidence-backed model capability profiles #5151. Only that profile carries the 3-attempt/1-second recovery facet. IC-705, IC-7300MK2, and unprofiled models declare no recovery capability, so their stall path stays diagnostic-only: no 0x04 command, recovery state, scheduler reset/failure, connection replacement, or link-timer change. Focused protocol/scheduler/meter/family/backend regressions pass 5/5. The timing-sensitive icom_session_test retains the current-main early-renewal baseline failure; no timer was changed to mask it.