Skip to content

Fix mDNS registration for multi-provider daemons#30

Merged
BlockedPath merged 2 commits into
mainfrom
fix/claude-multi-source-limits
May 21, 2026
Merged

Fix mDNS registration for multi-provider daemons#30
BlockedPath merged 2 commits into
mainfrom
fix/claude-multi-source-limits

Conversation

@BlockedPath

Copy link
Copy Markdown
Owner

Summary

When both codex (port 9595) and claude (port 9596) daemons ran, the second one's register_service would fail with an empty error message:

[06:43:47] mDNS advertise failed during register:

Two distinct bugs were at play:

  1. Name collision. Both daemons registered as codexmeter._http._tcp.local.. Renamed to codexmeter-{PROVIDER}._http._tcp.local. (so codexmeter-codex / codexmeter-claude) and added provider to TXT properties.

  2. The actual cause of the failures: EventLoopBlocked. Improved error logging revealed the empty str(exc) was hiding EventLoopBlocked(). Calling sync Zeroconf.register_service from inside asyncio.run(main()) deadlocks on Python 3.14 + zeroconf 0.148. Reproduced in isolation, then switched to AsyncZeroconf with await azc.async_register_service(info). start_http_server is now async and awaited from main. atexit cleanup uses the underlying sync Zeroconf (safe because asyncio.run has terminated by then).

Test plan

  • Both daemons restart cleanly via launchctl kickstart
  • Logs show mDNS advertisement active: codexmeter-codex._http._tcp.local. at http://...:9595 and codexmeter-claude…:9596
  • dns-sd -B _http._tcp local. lists both codexmeter-codex and codexmeter-claude as separate Instance Names
  • curl http://localhost:9595/status and :9596/status return 200
  • iOS discovery untouched — MDNSBrowser.swift matches on service.name.lowercased().contains("codexmeter"), which catches both new names

Notes for reviewer

  • The iOS processDiscovery in MeterViewModel.swift currently assigns the first discovered URL to codexServerURL regardless of provider. With two daemons now visible this is worth revisiting (route by provider TXT record or codexmeter-{provider} suffix), but it's outside this fix's scope.

🤖 Generated with Claude Code

Two issues prevented both daemons (codex on 9595, claude on 9596) from
advertising via mDNS simultaneously:

1. Both registered with the same instance name `codexmeter._http._tcp.local.`
   so the second registration would collide. Renamed to
   `codexmeter-{PROVIDER}._http._tcp.local.` and added `provider` to TXT
   properties.

2. The real cause of the "mDNS advertise failed during register:" empty
   errors was EventLoopBlocked, not the name collision. Calling sync
   `Zeroconf.register_service` from inside `asyncio.run(main())`
   deadlocks on Python 3.14 + zeroconf 0.148. Switched to `AsyncZeroconf`
   with `await azc.async_register_service(info)`. Made `start_http_server`
   async and await it from main. Cleanup uses the underlying sync
   `Zeroconf` at atexit time (safe once the asyncio loop has exited).

Also improved error logging to include exception type/repr so future
failures aren't silent.

Verified: `dns-sd -B _http._tcp local.` shows both `codexmeter-codex`
and `codexmeter-claude`; both /status endpoints return 200. iOS
discovery is unaffected because MDNSBrowser matches on
`name.contains("codexmeter")`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 97d35c221b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread daemon/codex-usage-daemon.py Outdated
The atexit handler called sync unregister_service after asyncio.run()
had already closed the loop, which raised "RuntimeError: Event loop is
closed" and emitted "coroutine 'async_unregister_service' was never
awaited" (reproducible with --transport none --once). The goodbye/
unregister packet was not reliably sent on shutdown.

- Replace atexit + sync cleanup with async _async_cleanup_zeroconf()
  that awaits async_unregister_service + async_close while the loop is
  still alive.
- Invoke it from main()'s finally block so it runs on normal exit,
  KeyboardInterrupt, or any unwind.
- Install a SIGTERM signal handler that sets an asyncio.Event;
  run_http_loop now races against that event so launchctl-initiated
  shutdowns reach the finally block.

Verified --transport none --once no longer emits the RuntimeWarning,
and SIGTERM to a running daemon now logs "Unregistered mDNS service
and closed Zeroconf".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@BlockedPath
BlockedPath merged commit 0f79d43 into main May 21, 2026
4 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.

1 participant