Fix #3420 (recovered after Claude Code exited before committing)#3421
Fix #3420 (recovered after Claude Code exited before committing)#3421aethersdr-agent[bot] wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Thanks for picking up #3420. The fix matches the issue analysis well — it implements the three-layered safety the issue suggested (pause-on-connect + retry cap + reset-on-stop), and the primary setConnected(true) path directly kills the unbounded re-bind churn on routed/VPN sessions, which is the reported bug.
Verified the wiring: MainWindow::onConnectionStateChanged() calls m_discovery.setConnected(connected) after m_connPanel->setConnected(connected), and the disconnect path correctly resets m_rebindAttempts while only restarting the timer if the socket is still bound and we never received a packet. Good preservation of passive Multi-Flex discovery while connected (socket stays bound, just no churn).
A few observations:
Minor — bind-retry interaction. setConnected(true) stops m_rebindTimer but does not stop m_bindRetryTimer. If the initial bind failed and the user connects-by-IP before the 15-retry bind window expires, the bind-retry path will still fire startListening(), which now correctly skips re-arming m_rebindTimer (thanks to the new !m_connected guard) but does still try to re-bind the UDP socket. That's bounded (max 15 × 2s = 30s) and largely benign, but if you want to be tidy, stopping m_bindRetryTimer in setConnected(true) would close it off completely.
Minor — m_receivedAny semantics. On stopListening() we reset m_receivedAny = false (correctly), but on setConnected(false) we don't. That's intentional and correct (if we ever received a broadcast this session the socket is healthy), but worth a one-line comment if you respin: future readers may wonder why disconnect doesn't symmetrically clear it.
Test plan suggestion. The issue is reproducible — could you confirm in the PR body that you've verified on a routed/Connect-by-IP session that the RadioDiscovery: no packets yet, re-binding socket log line stops appearing after onConnectionStateChanged(true) fires, and resumes (with a fresh retry budget) after disconnect? The unit-level logic looks right but this is the kind of fix that benefits from a quick log capture.
Nothing blocking. Nice constructive fix to a long-standing lifecycle gap.
🤖 aethersdr-agent · cost: $18.3036 · model: claude-opus-4-7
Pull request was closed
…routed/WAN (#3420) (#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 #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 - 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 #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 #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 --------- Co-authored-by: aethersdr-agent[bot] <273844287+aethersdr-agent[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…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 #3420
What was changed
Fix #3420 (recovered after Claude Code exited before committing)
Files modified
src/core/RadioDiscovery.cppsrc/core/RadioDiscovery.hsrc/gui/MainWindow.cppGenerated by AetherClaude (automated agent for AetherSDR)
🤖 aethersdr-agent · cost: $10.1494 · model: claude-opus-4-7