Skip to content

Fix #3420 (recovered after Claude Code exited before committing)#3421

Closed
aethersdr-agent[bot] wants to merge 1 commit into
mainfrom
aetherclaude/issue-3420
Closed

Fix #3420 (recovered after Claude Code exited before committing)#3421
aethersdr-agent[bot] wants to merge 1 commit into
mainfrom
aetherclaude/issue-3420

Conversation

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Summary

Fixes #3420

What was changed

Fix #3420 (recovered after Claude Code exited before committing)

Files modified

  • src/core/RadioDiscovery.cpp
  • src/core/RadioDiscovery.h
  • src/gui/MainWindow.cpp
 src/core/RadioDiscovery.cpp | 47 ++++++++++++++++++++++++++++++++++++++++++---
 src/core/RadioDiscovery.h   | 12 ++++++++++++
 src/gui/MainWindow.cpp      |  7 +++++++
 3 files changed, 63 insertions(+), 3 deletions(-)

Generated by AetherClaude (automated agent for AetherSDR)


🤖 aethersdr-agent · cost: $10.1494 · model: claude-opus-4-7

@jensenpat

Copy link
Copy Markdown
Collaborator

Superseded by #3422, which contains this change plus the routed/VPN/SmartLink full-quiesce refinement (option 3 from #3420) and on-hardware verification on a FLEX-8400M (connect-by-IP). Suggest closing this in favor of #3422.

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

@jensenpat jensenpat closed this Jun 6, 2026
auto-merge was automatically disabled June 6, 2026 03:51

Pull request was closed

NF0T pushed a commit that referenced this pull request Jun 6, 2026
…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>
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

1 participant