Skip to content

fix(discovery): pause re-bind loop while connected, fully quiesce on routed/WAN (#3420)#3422

Merged
NF0T merged 2 commits into
aethersdr:mainfrom
jensenpat:fix/discovery-rebind-3420
Jun 6, 2026
Merged

fix(discovery): pause re-bind loop while connected, fully quiesce on routed/WAN (#3420)#3422
NF0T merged 2 commits into
aethersdr:mainfrom
jensenpat:fix/discovery-rebind-3420

Conversation

@jensenpat

Copy link
Copy Markdown
Collaborator

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)

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.hsetConnected(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

aethersdr-agent Bot and others added 2 commits June 6, 2026 02:32
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>
@jensenpat jensenpat marked this pull request as ready for review June 6, 2026 03:50
@jensenpat jensenpat requested a review from a team as a code owner June 6, 2026 03:50

@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.

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 matching setConnected(false) resets m_rebindAttempts and re-enters startListening(), which re-binds and re-arms the bounded loop (since m_connected is now false). The defensive if (m_connected == connected) return; covers any duplicate connectionStateChanged emits.
  • MAX_REBIND_RETRIES = 12 is checked in both the timer lambda and the startListening() guard — clean defense in depth.
  • The remoteConnection predicate in MainWindow.cpp:10586-10587 lines up with how RadioModel::m_lastInfo is populated before connectionStateChanged(true) is emitted, and isWan() reads the live m_wanConn pointer, so both checks are valid in the handler.

A couple of small things I noticed — not blockers, just observations:

  1. Stale m_lastSeen after a routed session. On the routed path we stop m_staleTimer and close the socket. The m_lastSeen timestamps continue to age during the (potentially long) connected window, so when discovery resumes on disconnect, the next stale sweep will likely radioLost(...) everything that was in the picker before the routed connect. Mostly cosmetic (the picker briefly empties until fresh broadcasts arrive), but you might consider clearing m_lastSeen/m_radios on the remote-quiesce path so the resume isn't a flurry of radioLost followed by radioDiscovered. Or leave it — the next broadcast will re-upsert and the lost signal is correct in principle.

  2. m_receivedAny is not reset on disconnect. If a session connected purely via "Connect by IP" without ever having received a broadcast, m_receivedAny is still false and the resumed re-bind loop will run — which is exactly what you want. But if m_receivedAny was set earlier (LAN session that started locally), the resumed startListening() 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 NF0T self-assigned this Jun 6, 2026
@NF0T NF0T enabled auto-merge (squash) June 6, 2026 15:58

@NF0T NF0T 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.

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.

@NF0T NF0T merged commit 86d46e5 into aethersdr:main Jun 6, 2026
5 checks passed
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…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>
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.

Radio discovery re-bind loop runs forever while connected via IP (routed/VPN) — never stopped on connect

2 participants