fix: mDNS network race + event-driven UI feedback#30
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Root cause:
_main()ran before SteamOS finished bringing wifi up.get_local_ip()returned127.0.0.1, mDNS registered against loopback, the service was effectively invisible. The Status panel even displayed a freshget_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:
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 returnsOptional[str].get_status()returns what mDNS is actually announcing instead of a fresh probe. Network section folded into Status in the UI.feat(mdns): event-driven lifecycle callbacks— MDNSService acceptson_register/on_unregistercallbacks invoked from the watcher thread. Plugin wires these to a thread-safe_emit_eventhelper that pushes refresh events through the existing settings-based bus. Same pattern ashub_connected/pairing_success.feat(ui): toast notifications for mdns transitions— addson_waitingcallback 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.fix(mdns): re-register after network recovery (suspend/wake)— uses the_notified_waitingflag 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):
Suspend/wake (same IP after wake):
End-to-end recovery: ~2.7s, transparent to user and Hub.
Test plan
Notes for review
get_eventbus, no new frontend polling._emit_eventis a sync method intentionally, so it is callable from the watcher thread without bridging the asyncio loop.on_register(ip, port)callback payload — never a fallback or stale value.