Skip to content

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

Description

@jensenpat

Summary

While a radio is actively connected via IP (the routed / VPN / "Connect by IP" path), RadioDiscovery keeps a 5-second background re-bind loop running forever. The log shows a continuous cycle of:

RadioDiscovery: no packets yet, re-binding socket
RadioDiscovery: listening on UDP 4992

every 5 seconds, even though we already have a healthy TCP session to the radio. Discovery is never stopped or quiesced on connect, and the re-bind loop has no termination condition other than "first UDP broadcast packet received" — which by design never happens on a routed/VPN connection.

Reproduced on a FLEX-8400M connected over IP. The same condition occurs on macOS whenever Local Network privacy consent silently drops inbound broadcasts (a case the code already comments on).

Root cause

Discovery (src/core/RadioDiscovery.{h,cpp}) is a UDP broadcast listener on port 4992. Its lifecycle:

  1. Started once at app launchMainWindow.cpp:4530-4531:
    if (m_titleBar) m_titleBar->setDiscovering(true);
    m_discovery.startListening();
  2. Only stopped at app shutdown (MainWindow.cpp:5310) or during a manual "Retry Discovery" (MainWindow.cpp:1491-1493).
  3. onConnectionStateChanged(true) (MainWindow.cpp:8355) — the successful-connect handler — never touches discovery. There is no m_discovery.stopListening() (or pause) anywhere on the connect path. Confirmed: the only startListening/stopListening call sites in the whole app are launch, shutdown, and the retry button.

The re-bind loop itself, in RadioDiscovery.cpp:24-28:

m_rebindTimer.setInterval(REBIND_INTERVAL_MS);          // 5000 ms
connect(&m_rebindTimer, &QTimer::timeout, this, [this]() {
    qCDebug(lcDiscovery) << "RadioDiscovery: no packets yet, re-binding socket";
    startListening();                                    // close() + re-bind()
});

It is armed in startListening() on every bind whenever no packet has ever arrived:

if (!m_receivedAny) {
    m_rebindTimer.start();
}

…and the only thing that stops it is the first inbound broadcast, in onReadyRead():

if (!m_receivedAny) {
    m_receivedAny = true;
    m_rebindTimer.stop();   // <- the sole exit from the loop
}

Note there is no retry cap on the re-bind loop (unlike the bind-failure path, which gives up after MAX_BIND_RETRIES = 15). Re-bind is unbounded — see the constants at RadioDiscovery.h:100-104.

Why it loops forever specifically when connected via IP

"Connect by IP" is the routed/VPN/manual path: ConnectionPanel::probeRadio() opens a direct TCP socket to ip:4992 (ConnectionPanel.cpp:1323) and tags the result info.isRouted = true (ConnectionPanel.cpp:1367). The panel's own UI text spells out the precondition (ConnectionPanel.cpp:458-461):

"Use this path for VPN or other routed networks where discovery broadcasts cannot reach the radio."

and the probe code repeats it (ConnectionPanel.cpp:1463-1466):

"…populate mf_enable and connected client info even when there's no discovery broadcast (direct IP / VPN / routed path)."

So on a routed/VPN connection, UDP broadcasts never reach the client by definitionm_receivedAny stays false → the 5-second re-bind loop runs indefinitely in the background, closing and re-binding the UDP socket forever, while we hold a perfectly healthy TCP connection to the radio. That is the loop in the log.

The discovery subsystem and the active connection are completely independent and do not coordinate — discovery has no idea we've connected (by IP or otherwise), so it keeps "searching."

Answers to the design questions this raised

  • When do we use discovery, and over what transport? Discovery = UDP broadcast listener on port 4992 (RadioDiscovery). It exists to (a) populate the connection panel's local-radio list, (b) auto-connect to the last radio by serial (MainWindow.cpp:1537), and (c) refresh Multi-Flex / other-GUI-client info. The three connection transports are otherwise separate: local (discovery-assisted, TCP), routed / Connect-by-IP (direct TCP probe, no discovery), and WAN/SmartLink (TLS + cloud lookup). Discovery is broadcast-only and is not the connection mechanism for any of them — it just feeds the picker.
  • Why am I "using discovery" while connected by IP? You aren't, for the session — it's just never told to stop. It runs in parallel and, on a routed link, spins the re-bind loop forever because broadcasts can't arrive.
  • When do we shut discovery down? Only at app exit and momentarily on manual retry. Never on connect, and never when connecting by IP. The re-bind sub-loop never self-terminates unless a broadcast is received.

Impact

  • Continuous log spam every 5 s for the entire session.
  • Continuous, pointless close() + re-bind() of a UDP socket every 5 s (wasted syscalls / churn) for the whole time you're connected by IP.
  • Misleading: looks like the app "can't find the radio" while it's fully connected and working.
  • The amber "Searching for radio…" discovery indicator state (TitleBar::setDiscovering) is driven separately and cleared by heartbeats, so the UI doesn't show it — but the underlying subsystem is still thrashing.

Proposed fix (options)

The safe, clearly-correct part is bounding/stopping the re-bind loop so it cannot run forever:

  1. Stop the re-bind churn on connect. In onConnectionStateChanged(true), tell RadioDiscovery we're connected so it stops the re-bind timer (and optionally goes passive). Resume normal discovery on disconnect. This directly fixes the reported loop.
  2. Cap the re-bind attempts the same way the bind-failure path is capped (MAX_BIND_RETRIES), so even when we never connect, discovery stops thrashing after N tries instead of forever.
  3. Suppress re-bind for routed/isRouted connections — when the active radio was reached by direct IP/VPN, broadcasts cannot arrive by design, so the "keep re-binding until first packet" heuristic should not apply at all.

The smaller open design question (for maintainer direction): while connected, should discovery (a) fully stop, or (b) keep passively listening (no re-bind churn) so the connection panel can still refresh Multi-Flex / other-client info and support auto-reconnect? Option (b) preserves the most behavior; option (a) is simplest. Either way, the unbounded 5-second re-bind loop should not run while connected.

Notes

  • Purely client-side networking — no radio-authoritative state involved.
  • The core fix (stop/cap the re-bind loop; it's a missing lifecycle guard with a clear root cause) falls within the autonomous-fix boundaries in CLAUDE.md. The "fully stop vs. passively listen while connected" choice is the only part that wants maintainer confirmation.
  • Related but distinct (not a duplicate): FlexRadio discovery does not detect radios on Windows 11 #2718 (closed) was about discovery failing to detect radios on Windows 11.

Metadata

Metadata

Assignees

No one assigned

    Labels

    GUIUser interfacebugSomething isn't workingmacOSmacOS-specific issuemaintainer-reviewRequires maintainer review before any action is takenpriority: mediumMedium priorityprotocolSmartSDR protocol

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions