fix(discovery): pause re-bind loop while connected, fully quiesce on routed/WAN (#3420)#3422
Conversation
Extend the connection-lifecycle coupling so routed/VPN and SmartLink/WAN sessions — where local UDP broadcasts cannot reach the client by design — fully pause discovery (stop the stale sweep and release the UDP socket) rather than only halting the 5-second re-bind churn. Local sessions keep passive listening so Multi-Flex / other-GUI-client broadcasts still refresh the radio list. Discovery resumes on disconnect with a fresh retry budget. setConnected() gains a `remote` flag, computed in MainWindow from RadioModel::isWan() || lastRadioInfo().isRouted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Nicely scoped fix — thanks @jensenpat. The behavior matrix in the description matches the code, and the lifecycle math holds up:
setConnected(true, remote=true)stops both timers and closes the socket; the matchingsetConnected(false)resetsm_rebindAttemptsand re-entersstartListening(), which re-binds and re-arms the bounded loop (sincem_connectedis now false). The defensiveif (m_connected == connected) return;covers any duplicateconnectionStateChangedemits.MAX_REBIND_RETRIES = 12is checked in both the timer lambda and thestartListening()guard — clean defense in depth.- The
remoteConnectionpredicate inMainWindow.cpp:10586-10587lines up with howRadioModel::m_lastInfois populated beforeconnectionStateChanged(true)is emitted, andisWan()reads the livem_wanConnpointer, so both checks are valid in the handler.
A couple of small things I noticed — not blockers, just observations:
-
Stale
m_lastSeenafter a routed session. On the routed path we stopm_staleTimerand close the socket. Them_lastSeentimestamps continue to age during the (potentially long) connected window, so when discovery resumes on disconnect, the next stale sweep will likelyradioLost(...)everything that was in the picker before the routed connect. Mostly cosmetic (the picker briefly empties until fresh broadcasts arrive), but you might consider clearingm_lastSeen/m_radioson the remote-quiesce path so the resume isn't a flurry ofradioLostfollowed byradioDiscovered. Or leave it — the next broadcast will re-upsert and the lost signal is correct in principle. -
m_receivedAnyis not reset on disconnect. If a session connected purely via "Connect by IP" without ever having received a broadcast,m_receivedAnyis stillfalseand the resumed re-bind loop will run — which is exactly what you want. But ifm_receivedAnywas set earlier (LAN session that started locally), the resumedstartListening()won't arm the re-bind timer at all. That's also fine for the common case, but if the user's network state changed during the routed session (interface flip, sleep/resume), we won't self-heal until the next connect cycle. Worth noting in case #3420-class reports recur in that flavor.
Both points are easy to defer to a follow-up. The on-hardware logs in the description are convincing — full quiesce within ~100 ms, no re-bind spam during the connected window, clean resume, counter cap and reset both observed. LGTM as a COMMENT review.
🤖 aethersdr-agent · cost: $12.5852 · model: claude-opus-4-7
NF0T
left a comment
There was a problem hiding this comment.
Clean fix — root cause analysis matches the code, and the three-layer
approach (churn stop, retry cap, full quiesce) correctly covers the
behavior matrix described in the PR.
Verified in the diff:
`setConnected()` idempotency guard handles duplicate `connectionStateChanged`
emissions. The `remote=true` path correctly stops both `m_rebindTimer` and
`m_staleTimer` and closes the socket; `remote=false` stops only the churn
and leaves the socket and stale sweep running for passive MultiFlEX updates.
The disconnect path ignores `remote` and always calls `startListening()` —
which re-opens the socket if it was released, since the re-bind path already
handles `state() != BoundState`. ✓
The triple guard in `startListening()` (`!m_connected && !m_receivedAny &&
m_rebindAttempts < MAX_REBIND_RETRIES`) is defense in depth — even an
unexpected `startListening()` call during an active session won't re-arm
the timer. ✓
`stopListening()` resetting `m_rebindAttempts` gives "Retry Discovery" a
fresh budget. ✓
`remoteConnection` predicate verified: `isWan()` and `lastRadioInfo()` both
exist on `RadioModel` and are valid in the connection-state handler since
`m_lastInfo` is populated before `connectionStateChanged(true)` is emitted.
On disconnect, the `remote` parameter is irrelevant (disconnect path ignores
it), so stale WAN state on disconnect is a non-issue. ✓
aethersdr-agent's two observations:
Both valid, both appropriately deferred. Stale `m_lastSeen` on routed-session
resume is cosmetic (picker briefly empties). `m_receivedAny` not resetting
on disconnect is an edge case that only bites if network state changed during
a routed session — common path unaffected.
One style nit (not blocking): `(#3420)` in the two new member-variable
comments will rot as the codebase evolves. Consider dropping the issue ref
from the member declarations and keeping it only in the commit message.
✅ Approving.
…routed/WAN (aethersdr#3420) (aethersdr#3422) ## Summary Fixes the background **discovery re-bind loop that runs forever while a radio is connected via IP** (routed / VPN / "Connect by IP"). `RadioDiscovery` was started once at launch and never coupled to the connection lifecycle, so its 5-second `close()` + re-`bind()` recovery loop — whose only exit was "first UDP broadcast received" — churned for the entire session on any link where broadcasts can't arrive (routed/VPN, SmartLink/WAN, or macOS Local Network consent silently dropping packets). Closes aethersdr#3420. **Supersedes aethersdr#3421** (the autonomous-agent fix) — this PR contains that work plus the routed/WAN full-quiesce refinement and on-hardware verification. Please close aethersdr#3421 as a duplicate. ## Root cause - Discovery is a **UDP broadcast listener** on port 4992. It was started at app launch and only stopped at app shutdown or a manual "Retry Discovery" — `onConnectionStateChanged(true)` never touched it. - `startListening()` arms a 5s `m_rebindTimer` whenever `!m_receivedAny`; the timer's lambda calls `startListening()` again (close + rebind). The **only** thing that stops it is the first inbound broadcast. - "Connect by IP" is the routed path (`info.isRouted = true`), used precisely *because discovery broadcasts cannot reach the radio* (the panel's own UI says so). So `m_receivedAny` never flips → the re-bind loop runs indefinitely, every 5 seconds, while a perfectly healthy TCP session is up. - There was **no retry cap** on the re-bind loop (unlike the bind-*failure* path, capped at `MAX_BIND_RETRIES`). ## What changed Three complementary fixes, addressing all three options proposed in aethersdr#3420: **1. Stop the re-bind churn on connect** — new `RadioDiscovery::setConnected(connected, remote)`, wired from `MainWindow::onConnectionStateChanged()`. On connect the 5s re-bind timer is stopped for the rest of the session. **2. Cap the re-bind attempts** — new `MAX_REBIND_RETRIES = 12` (60s) + `m_rebindAttempts` counter. Even with *no* connection event (e.g. no radio present, or macOS consent denied forever), discovery self-terminates the loop instead of thrashing indefinitely, logging a single give-up warning. The budget resets on disconnect and on `stopListening()`. **3. Fully quiesce on routed / VPN / SmartLink connections** (`remote = true`) — for links where local broadcasts can't reach us by design, keeping the socket bound buys nothing, so discovery stops the stale sweep **and releases the UDP socket** — completely idle for the session. Local connections keep the conservative behavior: only the re-bind churn stops; the socket stays bound and the stale sweep runs so Multi-Flex / other-GUI-client broadcasts still refresh the radio list passively. On **disconnect**, discovery resumes (`startListening()` re-binds the socket if it was released, restarts the stale sweep, and re-arms the bounded re-bind loop) so the next connection — possibly a different local radio — can still be found. ### `remote` predicate (MainWindow) ```cpp const bool remoteConnection = m_radioModel.isWan() || m_radioModel.lastRadioInfo().isRouted; m_discovery.setConnected(connected, remoteConnection); ``` `m_lastInfo` is set in `RadioModel::connectToRadio()` before `connectionStateChanged(true)` is emitted, so `isRouted`/`isWan()` are valid in the handler. ### Behavior matrix | State | Re-bind churn | Stale sweep | UDP socket | Rationale | |-------|:---:|:---:|:---:|-----------| | Connected — **local** | stopped | running | bound | passive Multi-Flex / other-client updates still flow | | Connected — **routed / VPN / WAN** | stopped | stopped | **released** | broadcasts can't reach us; nothing to listen for | | Disconnected | resumes (bounded ≤12) | running | bound | find the next radio for the picker | | Never connects / no radio | self-terminates after 12 (~60s) | running | bound | no infinite thrash | ## Files changed - `src/core/RadioDiscovery.h` — `setConnected(bool, bool)`, `MAX_REBIND_RETRIES`, `m_rebindAttempts`, `m_connected`. - `src/core/RadioDiscovery.cpp` — bounded re-bind lambda, connect-aware `startListening()` guard, `setConnected()` implementation. - `src/gui/MainWindow.cpp` — compute `remote` and call `setConnected()` from the connection-state handler. ## Test plan Manual steps: 1. **Connect by IP (routed) to a FlexRadio.** Expect discovery to fully pause on connect and stay silent — no `re-binding` lines — for the whole session. 2. **Temp disconnect.** Expect `resuming discovery` + a fresh `listening`; the picker is live again. 3. **Reconnect by IP.** Expect it to re-pause. 4. **Stay disconnected with no broadcast-reachable radio.** Expect the bounded loop to give up after 12 attempts (~60s). 5. **Connect to a locally-discovered radio.** Expect `local radio connected — pausing re-bind loop` and the radio list to keep updating passively. *(By design / code review — steps 1–4 were verified on hardware below.)* ### On-hardware results — FLEX-8400M, Connect-by-IP (routed) Verified across two app sessions (IP redacted by the logger). Steps 1–4 confirmed: **Routed connect → full quiesce; ~56s connected window with zero re-bind spam (the bug, gone):** ``` 20:33:38.259 RadioDiscovery: listening on UDP 4992 20:33:39.230 Auto-connecting to routed radio …203 20:33:39.318 RadioDiscovery: remote radio connected — discovery fully paused … 56 seconds connected — NO re-bind lines … ``` **Temp disconnect → resume; bounded re-bind while disconnected; reconnect → re-pause:** ``` 20:34:35.056 RadioDiscovery: radio disconnected — resuming discovery 20:34:35.057 RadioDiscovery: listening on UDP 4992 20:34:40.189 RadioDiscovery: no packets yet, re-binding socket (attempt 1 / 12 ) 20:34:45.188 RadioDiscovery: no packets yet, re-binding socket (attempt 2 / 12 ) 20:34:48.699 RadioDiscovery: remote radio connected — discovery fully paused ``` Observed: full quiesce within ~100 ms of every routed connect; the previously noisy connected window is now completely silent; disconnect cleanly resumes discovery; the re-bind counter increments and is capped, and resets to `attempt 1` on each new disconnect cycle. Builds clean on macOS (arm64, Qt6 / Homebrew, Ninja). ## Scope / risk - **Client-side networking only** — no radio-authoritative state is touched; nothing persisted. - The local "keep passively listening vs. fully quiesce" choice is intentionally conservative (local keeps listening). Happy to flip locals to full quiesce too if preferred — it's a one-line change in `setConnected()`. ## Relationship to aethersdr#3421 This branch is built on aethersdr#3421's commit and adds the routed/WAN full-quiesce refinement (option 3 from the issue) plus on-hardware verification. Once this merges, aethersdr#3421 can be closed as superseded. 💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat --------- Co-authored-by: aethersdr-agent[bot] <273844287+aethersdr-agent[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Fixes the background discovery re-bind loop that runs forever while a radio is connected via IP (routed / VPN / "Connect by IP").
RadioDiscoverywas started once at launch and never coupled to the connection lifecycle, so its 5-secondclose()+ re-bind()recovery loop — whose only exit was "first UDP broadcast received" — churned for the entire session on any link where broadcasts can't arrive (routed/VPN, SmartLink/WAN, or macOS Local Network consent silently dropping packets).Closes #3420. Supersedes #3421 (the autonomous-agent fix) — this PR contains that work plus the routed/WAN full-quiesce refinement and on-hardware verification. Please close #3421 as a duplicate.
Root cause
onConnectionStateChanged(true)never touched it.startListening()arms a 5sm_rebindTimerwhenever!m_receivedAny; the timer's lambda callsstartListening()again (close + rebind). The only thing that stops it is the first inbound broadcast.info.isRouted = true), used precisely because discovery broadcasts cannot reach the radio (the panel's own UI says so). Som_receivedAnynever flips → the re-bind loop runs indefinitely, every 5 seconds, while a perfectly healthy TCP session is up.MAX_BIND_RETRIES).What changed
Three complementary fixes, addressing all three options proposed in #3420:
1. Stop the re-bind churn on connect — new
RadioDiscovery::setConnected(connected, remote), wired fromMainWindow::onConnectionStateChanged(). On connect the 5s re-bind timer is stopped for the rest of the session.2. Cap the re-bind attempts — new
MAX_REBIND_RETRIES = 12(60s) +m_rebindAttemptscounter. Even with no connection event (e.g. no radio present, or macOS consent denied forever), discovery self-terminates the loop instead of thrashing indefinitely, logging a single give-up warning. The budget resets on disconnect and onstopListening().3. Fully quiesce on routed / VPN / SmartLink connections (
remote = true) — for links where local broadcasts can't reach us by design, keeping the socket bound buys nothing, so discovery stops the stale sweep and releases the UDP socket — completely idle for the session. Local connections keep the conservative behavior: only the re-bind churn stops; the socket stays bound and the stale sweep runs so Multi-Flex / other-GUI-client broadcasts still refresh the radio list passively.On disconnect, discovery resumes (
startListening()re-binds the socket if it was released, restarts the stale sweep, and re-arms the bounded re-bind loop) so the next connection — possibly a different local radio — can still be found.remotepredicate (MainWindow)m_lastInfois set inRadioModel::connectToRadio()beforeconnectionStateChanged(true)is emitted, soisRouted/isWan()are valid in the handler.Behavior matrix
Files changed
src/core/RadioDiscovery.h—setConnected(bool, bool),MAX_REBIND_RETRIES,m_rebindAttempts,m_connected.src/core/RadioDiscovery.cpp— bounded re-bind lambda, connect-awarestartListening()guard,setConnected()implementation.src/gui/MainWindow.cpp— computeremoteand callsetConnected()from the connection-state handler.Test plan
Manual steps:
re-bindinglines — for the whole session.resuming discovery+ a freshlistening; the picker is live again.local radio connected — pausing re-bind loopand the radio list to keep updating passively. (By design / code review — steps 1–4 were verified on hardware below.)On-hardware results — FLEX-8400M, Connect-by-IP (routed)
Verified across two app sessions (IP redacted by the logger). Steps 1–4 confirmed:
Routed connect → full quiesce; ~56s connected window with zero re-bind spam (the bug, gone):
Temp disconnect → resume; bounded re-bind while disconnected; reconnect → re-pause:
Observed: full quiesce within ~100 ms of every routed connect; the previously noisy connected window is now completely silent; disconnect cleanly resumes discovery; the re-bind counter increments and is capped, and resets to
attempt 1on each new disconnect cycle. Builds clean on macOS (arm64, Qt6 / Homebrew, Ninja).Scope / risk
setConnected().Relationship to #3421
This branch is built on #3421's commit and adds the routed/WAN full-quiesce refinement (option 3 from the issue) plus on-hardware verification. Once this merges, #3421 can be closed as superseded.
💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat