Skip to content

fix(rigctld): resolve SmartSDR rigctld behavior gaps and fix on-demand split VFO#3724

Merged
ten9876 merged 7 commits into
aethersdr:mainfrom
K5PTB:fix/cat-rigctld-behavior
Jun 23, 2026
Merged

fix(rigctld): resolve SmartSDR rigctld behavior gaps and fix on-demand split VFO#3724
ten9876 merged 7 commits into
aethersdr:mainfrom
K5PTB:fix/cat-rigctld-behavior

Conversation

@K5PTB

@K5PTB K5PTB commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

⚠️ Filed as a draft PR — holding until after the current release cycle. The work is complete, reviewed,
and tested across platforms; it is intentionally kept in draft so it doesn't land mid-cycle. It will be
marked ready for review once the current release cycle closes.

Summary

This started as the rigctld-vs-SmartSDR behavior-difference fixes from the #3703 triage. While
implementing and testing them, integration testing with WSJT-X "Rig" split surfaced several real split-VFO bugs, which are fixed here too. Finally, the regression test — which had accreted a lot of one-off tweaks — is cleaned up after adding coverage for the new behavior.

1. rigctld behavior alignment (#3703 triage)

  • Implicit-enable state: split enabled via set_freq/set_mode VFOB (WSJT-X "Rig" split) is now
    recorded, so a later set_split_vfo 0 reclaims TX to the RX slice.
  • maxSlices guard: at capacity, set_split_vfo 1 fails cleanly instead of arming a create that can
    never land (which left split stuck "pending").

2. Split-VFO fixes found during integration testing (WSJT-X "Rig" split)

  • TX frequency: the on-demand TX slice is created at the RX frequency instead of addSlice()'s default geometry (it was landing several kHz off).
  • No duplicate slice: an in-flight guard stops back-to-back set_freq/set_mode VFOB from creating two TX slices.
  • PTT routing: keying transmits on the split slice (VFOB), not the RX VFO — previously the PTT path seized TX back to VFOA, so the radio transmitted on the wrong VFO.
  • Establish-on-demand: set_split_freq/set_split_mode now create/promote the TX slice rather than returning RPRT -1 and aborting the transmit on a race.
  • Cleanup: best-effort removal of the slice we created on disable/disconnect; operator-owned slices are never removed.
  • Crash fix: closing the split TX slice out-of-band no longer causes a use-after-free (m_pendingTxSlice is now QPointer-guarded).

3. Code-review hardening

A high-effort review of the above produced three further fixes: a time-bounded in-flight guard so a
NAK'd/lost create (incl. Multi-Flex where the local slice count understates the radio's) can't wedge split
"pending" forever; PTT no longer seizes TX during the create window; and a passive
set_split_freq/set_split_mode no longer arms a phantom reclaim edge that could steal TX off a slice the client never put into split.

4. Tests

  • Added coverage for the new behavior: 5.12 implicit-enable reclaim, 5.13 maxSlices guard
    (conditional), 5.14 set_split_freq/set_split_mode establish-on-demand.
  • Cleaned up the regression test: the repeated "poll get_split_vfo until 0/1" loops (≈8 copies) are
    factored into waitForSplit() / resetSplitOff() helpers, and the set_split_freq/set_split_mode
    create-on-demand prologue is shared via applySplitParam(). Net ≈70 fewer lines.
  • rigctld_test: 183/184. The one failure (5.10b) is a documented single-slice create-status-echo
    timing race that is slice-count dependent (passes on the promote path); it is not a regression from
    this PR.

Scope / deferred

This grew beyond the original #3703 triage (two narrow bugs) into making WSJT-X "Rig" split work end-to-end. The remaining create-on-demand fragilities the review confirmed — operator-slice mis-target (no create-ack id), id reuse, fast-disable orphan, shutdown-time deref — are deferred to the slice-lifecycle RFC #3715, whose create-ack id / handle is the proper fix. A separate, app-side DAX-assign-timer UAF (only reachable by the test's rapid create/remove churn, not normal client use) is tracked as a to-do; the test settles around it.

Addresses #3703 (triage bugs fixed; the create-on-demand-vs-NOT_ENABLED policy alignment is deferred to #3715). Refs #3715.


Built and regression tested on MacOS (M2), Linux (RPi 5) and Windows 11. Integration testing (WSJT-X 3.0.0, fldigi-4.2) on MacOS (M2).

🤖 Generated with Claude Code

K5PTB and others added 7 commits June 21, 2026 12:11
Two behavior corrections to the rigctld split path, independent of the
slice-lifecycle cleanup (which is tracked separately):

- Implicit split-enable was not recorded. WSJT-X "Rig" split enables split via
  set_freq/set_mode VFOB (→ ensureSplitTxSlice) without ever sending
  set_split_vfo, so m_lastSplitEnable stayed unset; a later set_split_vfo 0 then
  saw no 1→0 edge and never reclaimed TX onto the RX slice. ensureSplitTxSlice
  now records m_lastSplitEnable=1 whenever split actually engages, and
  cmdSetSplitVfo no longer blanket-sets the flag (it sets 0 on disable and lets
  the engage path set 1). A set_split_vfo 1 that can't engage therefore can't
  leave a false "enabled" state that a subsequent disable would act on.

- ensureSplitTxSlice now guards the radio's slice-receiver limit before
  addSlice(): on a full radio the create would never land, leaving split stuck
  "pending" forever (get_split_freq → -1). It now bails cleanly instead.

No change to the slice create/track/remove machinery — created-slice cleanup
(orphan/race) is deferred to the slice-management consolidation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rsdr#3703)

5.12 — implicit split enable via set_freq VFOB must record the enable so
a later set_split_vfo 0 reclaims TX (regression for aethersdr#3703 bug a).
5.13 — conditional guard test: on a single-slice-capacity port, set_split_vfo 1
must fail cleanly (get_split_freq RPRT -1) rather than arm a phantom create
(aethersdr#3703 bug b); skips when the radio has slice capacity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…emand)

Four cohesive fixes to the on-demand split path, all in RigctlProtocol, found via
WSJT-X "Rig" split integration testing:

1. Create the TX slice at the RX frequency (addSliceOnPan) instead of addSlice()'s
   default geometry (pan-center + 20% BW), so the split slice is born
   on-frequency — WSJT-X sends set_split_freq AFTER the slice is created.
2. Guard ensureSplitTxSlice() against an already-in-flight create
   (m_pendingSplitEnable) so back-to-back set_freq/set_mode VFOB don't create
   duplicate TX slices, and the stashed split freq/mode isn't dropped by the
   promote-existing path.
3. Don't seize TX onto the bound (RX) slice on PTT while split is active, so the
   radio transmits on the split slice (VFOB), not VFOA.
4. set_split_freq / set_split_mode establish the TX slice on demand (like
   set_freq/set_mode VFOB), so a direct or race-timed call resolves instead of
   returning RPRT -1 and aborting the transmit.

Manually verified on-air with WSJT-X: TX lands on VFOB at the WSJT-X-set
frequency, following VFOB retunes. These extend the create-on-demand path the
aethersdr#3703 triage flagged (aethersdr#1/aethersdr#2); the broader consolidation remains RFC aethersdr#3715.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…inter guard)

Follow-on to the create-on-demand split fixes, found via WSJT-X integration
testing:

- Best-effort removal of the TX slice we created on demand: track its id in
  tryPromoteTxSlice (only reached after our own create, so never an operator
  slice), and remove it on a genuine split→non-split transition (set_split_vfo 0)
  and in a new ~RigctlProtocol() destructor (hard disconnect, e.g. WSJT-X quit).
  Re-resolved by id at removal time and skipped if already gone; m_model is the
  invoke context so a destroyed model cancels the queued call. Operator-promoted
  slices are never removed.
- QPointer-guard m_pendingTxSlice: if the user closes the split TX slice
  out-of-band, the raw pointer dangled and findTxSlice() handed it back, crashing
  on deref (UAF). QPointer auto-nulls so findTxSlice() returns nullptr and split
  re-establishes on demand. rigctld runs on the GUI thread (same as SliceModel),
  so the guard is safe; it was the only long-lived stored slice pointer in the
  class.

Robust create-ack-id ownership (eliminating the small mis-target window when a
slice is opened during the create) is deferred to RFC aethersdr#3715.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rdowns

5.14 — set_split_freq / set_split_mode from a split-off state must establish
split on demand (RPRT 0, not -1) and engage split, so a direct or race-timed call
(WSJT-X "Rig" split via Hamlib set_split_freq_mode) doesn't abort the transmit.

disableSplitSettled() helper (kDaxSettleMs=700) — settle before every on-demand
split teardown in Section 5. Closing a just-created TX slice can fire a deferred
DAX-channel-assign timer on the freed slice and crash ASDR; only this test churns
create/remove that fast, so the settle lets the timer fire on the live slice. The
underlying app-side DAX-assign UAF is tracked separately (out of scope here).

Run: rigctld_test --port 4532 → Section 5 incl. 5.14 passes (5.13 skips when the
radio has slice capacity). aethersdr#3703.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sdr#1-aethersdr#3)

From the high-effort review of the split work:

aethersdr#1 In-flight create guard is now time-bounded. The hard `if (m_pendingSplitEnable)
   return;` could wedge split "pending" forever if the create never landed for a
   reason other than local capacity (radio NAK, or Multi-Flex where local
   slices().size() < maxSlices() but the radio is really full — RadioModel has no
   sliceCreateFailed wiring here). After kPendingCreateTimeoutMs the guard clears
   the stale pending and lets the next call retry; the window still dedupes the
   rapid WSJT-X set_freq/set_mode VFOB burst.

aethersdr#2 PTT no longer seizes TX during the create window. splitActive now also covers
   m_pendingSplitEnable, so a key that arrives before the on-demand slice has
   appeared doesn't fall through to the seize and transmit on the RX VFO (VFOA).

aethersdr#3 Passive set_split_freq/set_split_mode no longer arm the reclaim edge. These now
   call ensureSplitTxSlice(recordExistingAsEnabled=false); when split is already
   engaged on a distinct slice they no longer set m_lastSplitEnable=1, so a client
   that never sent set_split_vfo 1 can't trigger a phantom 1->0 reclaim that steals
   TX off a slice it didn't put into split. Enable-intent callers (set_split_vfo 1,
   set_freq/set_mode VFOB) keep the default true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rologue (review aethersdr#8-aethersdr#10)

Test cleanup (aethersdr#9/aethersdr#10) — Section 5 had grown a hand-rolled "poll get_split_vfo
until Split==0/1 with timeout" loop copied ~8 times. Add two local helpers:
  - waitForSplit(want, timeoutMs) → returns the final Split value
  - resetSplitOff() → settle-disable + confirm off (the "known-off baseline")
and replace every duplicated poll/baseline loop (5.3, 5.9b, 5.11, 5.12, 5.13,
5.14) with them. No behavior change; rigctld_test still 183/184 (5.10b is the
known single-slice create-echo timing race, slice-count dependent). The
kDaxSettleMs=700 guard stays — it's deliberately masking the separate app-side
DAX-assign-timer UAF during the test's fast create/remove churn.

Production cleanup (aethersdr#8) — cmdSetSplitFreq and cmdSetSplitMode were copy-paste
twins of the create-on-demand contract (ensureSplitTxSlice(false) → stash if
pending → resolve TX slice → apply or RPRT -1). Factor it into applySplitParam()
so the two can't diverge (a divergence would reintroduce the aethersdr#3703 -1 symptom).
Left m_lastSplitEnable as per-branch writes: the records are intentional and the
recordExistingAsEnabled gate makes a single assignment awkward; the deeper
restructure belongs to RFC aethersdr#3715.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@K5PTB K5PTB changed the title fix(rigctld): resolve SmartSDR CAT behavior gaps and fix on-demand split VFO fix(rigctld): resolve SmartSDR rigctld behavior gaps and fix on-demand split VFO Jun 22, 2026
@K5PTB K5PTB marked this pull request as ready for review June 22, 2026 13:13
@K5PTB K5PTB requested review from a team as code owners June 22, 2026 13:13

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, @K5PTB — this is an unusually careful piece of work. I traced the changes against the surrounding code and they hold up well. A few notes below, none blocking.

Verified

  • Threading / QPointer is correct. The m_pendingTxSliceQPointer<SliceModel> change is sound, and the comment justifying it ("rigctld runs on the GUI thread, same thread as SliceModel") matches reality: CatPort is constructed with new CatPort(&m_radioModel, nullptr) in MainWindow_Session.cpp and is never moveToThread'd, so RigctlProtocol and its socket callbacks run on the GUI thread. The auto-null on out-of-band slice close genuinely closes the use-after-free, and the queued connections elsewhere remain harmless. (Worth noting the older tryPromoteTxSlice comments still describe a "CAT socket thread" — those read as over-cautious given the actual model, but they're pre-existing and not introduced here.)
  • API surface checks out. addSliceOnPan(panId, freqMhz), maxSlices(), slice(int), panId(), and frequency() all exist with the signatures used, and the maxSlices() capacity guard mirrors the existing GUI create paths (MainWindow_Wiring.cpp, MainWindow_Controllers.cpp).
  • PTT split gate is right. model->setTransmit(tx, …) stays outside the !splitActive guard, so keying still transmits — only the badge-seize back to the RX slice is suppressed. That's exactly the WSJT-X "Rig" split symptom fix, and covering the m_pendingSplitEnable window closes the key-arrives-before-slice-lands race.
  • m_lastSplitEnable refactor is consistent. Moving the =1 into ensureSplitTxSlice() only when split actually engages (and leaving it untouched at the maxSlices ceiling) correctly avoids the spurious 1→0 reclaim, and the implicit-enable path is covered by 5.12. The recordExistingAsEnabled=false distinction for the passive setters is a nice touch.

Minor / non-blocking

  1. Destructor cleanup depends on a live event loop. ~RigctlProtocol()removeCreatedTxSlice() posts a Qt::QueuedConnection. On a normal client disconnect this fires fine, but if the protocol is torn down during app shutdown after the event loop has stopped, the queued slice remove never runs and the slice is left behind. Best-effort by design, so I'd just suggest a one-line note in the comment that the queued path is no-op'd at shutdown — the radio session is going away then anyway.

  2. m_createdTxSliceId id-reuse window — you already call this out and defer the create-ack-id fix to #3715. Agreed that's the right home for it; flagging only so it's visible in the review thread. The "only slices WE created are tracked, operator slices never touched" invariant holds in the current code (m_createdTxSliceId is set solely on the create-on-demand promote path).

The test refactor (waitForSplit/resetSplitOff/applySplitParam, ~70 fewer lines) is a clear readability win, the new 5.12–5.14 coverage maps directly onto the behavior changes, and CI is green across build/macos/windows/CodeQL. The documented 5.10b timing race and the deferred fragilities are honestly scoped to #3715 rather than papered over, which I appreciate.

Nice work — no changes requested from me.


🤖 aethersdr-agent · cost: $2.6135 · model: claude-opus-4-8

@ten9876 ten9876 closed this in 80a63ca Jun 23, 2026
@ten9876 ten9876 reopened this Jun 23, 2026
@ten9876

ten9876 commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Reopened — this was closed by accident: the #3728 squash-merge commit message contained the phrase "Closes #3724-class disconnect crashes", and GitHub parsed "Closes #3724" as a close directive. That was sloppy wording on my part, not an intentional action. This PR is unaffected and remains held for after the current release cycle per your intent (@K5PTB), to be marked ready when the cycle closes. Apologies for the noise.

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Code verdict stands from the prior review: the QPointer use-after-free fix (m_pendingTxSlice), the split-active PTT gate so keying transmits on VFOB (not seizing TX back to VFOA), the time-bounded in-flight guard against a wedged 'pending', on-demand TX slice created at the RX frequency, best-effort cleanup of only the slice we created, and the maxSlices capacity guard are all correct and well-tested (183/184; the one failure a documented pre-existing timing race). FlexLib/WSJT-X 'Rig' split aligned, hardware-verified on a FLEX-8400M, CI green. The remaining create-on-demand fragilities are honestly scoped to the slice-lifecycle RFC #3715. Landing now per maintainer decision (the author's hold-for-next-cycle is being lifted by the operator). Thanks @K5PTB.

@ten9876 ten9876 merged commit f805c7d into aethersdr:main Jun 23, 2026
11 checks passed
@K5PTB K5PTB deleted the fix/cat-rigctld-behavior branch June 23, 2026 11:57
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.

2 participants