Skip to content

fix: mDNS network race + event-driven UI feedback#30

Merged
lobinuxsoft merged 4 commits into
mainfrom
fix/mdns-network-race
Apr 27, 2026
Merged

fix: mDNS network race + event-driven UI feedback#30
lobinuxsoft merged 4 commits into
mainfrom
fix/mdns-network-race

Conversation

@lobinuxsoft

Copy link
Copy Markdown
Owner

Summary

Fixes the boot-time mDNS announcement race and the random suspend/wake disconnections, and makes the lifecycle observable to the user via toast notifications.

Closes #28
Closes #29

Background

After every cold boot of the Steam Deck, the agent was invisible to the Hub on the LAN until the user manually toggled the plugin off and on. Verified across three boots in production logs:

09:51:22  mDNS will advertise on 127.0.0.1:34173
12:07:16  mDNS will advertise on 127.0.0.1:37617
12:10:38  mDNS will advertise on 127.0.0.1:39469

Root cause: _main() ran before SteamOS finished bringing wifi up. get_local_ip() returned 127.0.0.1, mDNS registered against loopback, the service was effectively invisible. The Status panel even displayed a fresh get_local_ip() value that did not match what mDNS was actually announcing — looked fine, was broken.

A second symptom appeared after suspend/resume: the watcher saw the same IP it had before, considered everything OK, and never re-registered. The zeroconf service was actually broken by the network drop, but the user saw a 'Waiting' toast that was never followed by a 'Ready' one. Toggle off/on was again the only recovery.

What changed

Four commits, each independently revertable:

  1. fix(mdns): wait for network at boot and re-register on IP change — replaces the one-shot registration with a watcher thread that waits for a usable IPv4 before registering, and re-registers when the IP changes (suspend/resume, AP roaming, DHCP renewal). get_local_ip() now returns Optional[str]. get_status() returns what mDNS is actually announcing instead of a fresh probe. Network section folded into Status in the UI.

  2. feat(mdns): event-driven lifecycle callbacks — MDNSService accepts on_register / on_unregister callbacks invoked from the watcher thread. Plugin wires these to a thread-safe _emit_event helper that pushes refresh events through the existing settings-based bus. Same pattern as hub_connected / pairing_success.

  3. feat(ui): toast notifications for mdns transitions — adds on_waiting callback fired the first time the watcher cannot find a usable IP. Frontend turns transitions into branded toasts. When multiple events are pending in one poll cycle, the toast for the most recent transition wins (timestamp-ordered) so a stale 'Waiting' never overwrites a fresh 'Ready'. Boot-time delay (_INITIAL_WAIT_SEC) dropped in favour of an unconditional first attempt + 1s retry on failure.

  4. fix(mdns): re-register after network recovery (suspend/wake) — uses the _notified_waiting flag as evidence that the network went down, forcing a re-register on the next valid IP even when the IP is unchanged. Closes the suspend/wake recovery gap end-to-end.

Verified on a real Steam Deck

Cold boot (network not yet up when plugin loaded):

mDNS watcher started for 0d8c9873 on port 38559
Frontend event: mdns_waiting - {}                         ← toast: Waiting for network…
mDNS service registered on 192.168.0.131:38559           ← 3.7s later
Frontend event: mdns_registered - {ip, port}             ← toast: Ready at 192.168.0.131:38559

Suspend/wake (same IP after wake):

Frontend event: mdns_waiting - {}                         ← network drop detected
mDNS recovering after network drop on 192.168.0.131       ← new log line from this PR
Frontend event: mdns_unregistered - {}                    ← forced tear-down
mDNS service registered on 192.168.0.131:37161            ← re-registered
Frontend event: mdns_registered - {ip, port}              ← Hub auto-reconnects via ServiceUpdated

End-to-end recovery: ~2.7s, transparent to user and Hub.

Test plan

  • Cold boot of Steam Deck with plugin enabled → mDNS announces real IP, Hub discovers in <5s without manual toggle
  • Toggle off → on with network already up → 'Ready' toast in ~1.5s
  • Suspend / wake cycle → 'Waiting' toast then 'Ready' toast, Hub reconnects automatically
  • Status panel shows real IP/Port (no longer the lying fresh-probe value); merged into Status section, Network section gone
  • No new toasts when toggle is off (zero polling, watcher thread does not exist)
  • CI: TypeScript check + Python py_compile (will validate on push)

Notes for review

  • Watcher polling (1s while no IP, 5s steady-state) is internal to the mDNS subsystem — the UI is fully event-driven via the existing get_event bus, no new frontend polling.
  • Python _emit_event is a sync method intentionally, so it is callable from the watcher thread without bridging the asyncio loop.
  • Toasts always show the real IP/port from the on_register(ip, port) callback payload — never a fallback or stale value.

The mDNS service was registered once at plugin load with whatever
get_local_ip() returned at that moment. On cold boot, the wifi/network
stack is not ready yet, so get_local_ip() returned 127.0.0.1 (loopback)
and the service was advertised against that — invisible from any other
host on the LAN. Toggling the plugin off/on was the only workaround,
because it recreated the MDNSService with a fresh IP.

Verified across three boots in production logs:
  09:51:22  mDNS will advertise on 127.0.0.1:34173
  12:07:16  mDNS will advertise on 127.0.0.1:37617
  12:10:38  mDNS will advertise on 127.0.0.1:39469

Changes:

steam_utils.py — get_local_ip() now returns Optional[str]:
  - Adds settimeout(1) to the UDP-connect probe so it fails fast.
  - Falls back to enumerating addresses bound to the hostname.
  - Returns None when no usable IPv4 is available, so callers can
    decide whether to retry or wait.

mdns_service.py — owns a watcher thread:
  - On start(), spawns a daemon thread that waits for a usable IP
    before registering, then polls every 5s and re-registers on
    IP change (handles suspend/resume, AP roaming, DHCP renewal).
  - On stop(), signals the watcher and unregisters cleanly.
  - announced_ip property exposes the IP currently being advertised.
  - Thread safety via internal lock.
  - Zero resources while stopped — toggle off destroys the service.

main.py — get_status() reports reality:
  - port and ip now come from MDNSService (what is actually being
    announced) instead of a fresh get_local_ip() call. Returns None
    when mDNS is not active so the UI can render an explicit
    'waiting for network' state instead of a misleading address.

Frontend (StatusPanel.tsx, useAgent.ts, index.tsx):
  - Network section removed; IP and Port live inside the Status
    section, gated by mDNS having registered.
  - When ip/port are null, shows 'Waiting for network…' instead of
    fake fallback values.
  - AgentStatus types updated to allow null.

Closes #28
Closes #29
The MDNSService now invokes optional on_register and on_unregister
callbacks when the watcher transitions between states. The Plugin
wires these into a sync _emit_event helper that pushes refresh
events through the existing settings-based bus.

This lets the frontend react immediately to backend-side mDNS state
changes without polling get_status — the same pattern already used
for hub_connected, hub_disconnected, and pairing events.

  - mdns_service.py: callbacks fire from the watcher thread; sync
    settings I/O makes them safe to call without the asyncio loop.
  - main.py: notify_frontend now delegates to a thread-safe
    _emit_event method. Two new handlers (_on_mdns_register and
    _on_mdns_unregister) emit mdns_registered / mdns_unregistered
    events with the actual ip/port that zeroconf announced.
  - eventPoller.tsx: both events trigger onRefreshStatus so the
    Status panel re-fetches on transition.
The mDNS watcher now signals a third lifecycle event (on_waiting)
the first time it cannot find a usable IP. The frontend turns the
event stream into branded toasts so the user gets explicit feedback
for both 'still waiting for the network' and 'service is ready at
this address'.

If multiple transitions are pending in a single poll cycle (typical
right after the panel opens), only the toast for the most recent one
is shown — picked by comparing event timestamps — so a stale
'Waiting' never overwrites a fresh 'Ready' on screen.

Also drops the 2-second initial wait at watcher start: it was a
boot-race safety margin that costs latency when the network is
already up. The same retry loop with a 1-second backoff handles
the cold-boot case without the unconditional delay.

  - mdns_service.py: on_waiting callback fires once per 'no IP'
    stretch; flag resets on a successful register so a future loss
    of network re-arms it. _INITIAL_WAIT_SEC removed in favour of
    _RETRY_INTERVAL_SEC = 1.0.
  - main.py: _on_mdns_waiting handler emits 'mdns_waiting' event;
    wired into both MDNSService construction sites.
  - eventPoller.tsx: drains all three mDNS events in one
    Promise.all, ranks them by timestamp, fires the latest one as
    a toast while still triggering a refresh for any change.
Previously, if the Steam Deck suspended and woke with the same LAN
IP, the watcher would observe ip == self._current_ip on its next
tick and skip the re-register. The zeroconf service was already
broken by the network drop, but the watcher had no way to know,
and the user saw a 'Waiting for network…' toast that was never
followed by a 'CapyDeploy ready' one. Toggling the plugin off and
on was the only way to recover.

Use the _notified_waiting flag as the missing signal: if the
watcher emitted mdns_waiting at any point, we know the network
went down even if it came back on the same IP, so we tear down
zeroconf and re-register. The flag clears on success, so steady
state stays a no-op tick.

Verified on a real Steam Deck across suspend/wake cycles — full
recovery in ~2.7s with the Hub auto-reconnecting via the
ServiceUpdated event.
@lobinuxsoft
lobinuxsoft merged commit 6ffe1f3 into main Apr 27, 2026
2 checks passed
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.

bug: agent not advertised on LAN until QAM panel is opened once bug: plugin requires toggle off/on after SteamOS boot

1 participant