Skip to content

Part 6 - p2p: replace upnpclient with async-upnp-client for NAT port mapping#970

Open
ping-ke wants to merge 24 commits intoupgrade/py313-baselinefrom
upgrade/p2p-nat
Open

Part 6 - p2p: replace upnpclient with async-upnp-client for NAT port mapping#970
ping-ke wants to merge 24 commits intoupgrade/py313-baselinefrom
upgrade/p2p-nat

Conversation

@ping-ke
Copy link
Copy Markdown
Contributor

@ping-ke ping-ke commented Mar 15, 2026

Summary

upnpclient uses synchronous/blocking I/O and netifaces for interface discovery; neither is well-maintained on Python 3.13.

Rewrite UPnPService in nat.py to use async-upnp-client:

  • Use async_search() for UPnP IGD device discovery
  • Use AiohttpSessionRequester + UpnpFactory for async HTTP requests
  • Remove ThreadPoolExecutor blocking path and netifaces dependency
  • Expose discover() coroutine (previously add_nat_portmap()) that
    returns the mapped external IP string
  • Update p2p_server.py call site accordingly

Test plan

  • Node starts and attempts UPnP discovery without errors
  • NAT mapping is established when a compatible router is present
  • Graceful handling when no UPnP device is found (returns None)

upnpclient uses synchronous I/O and netifaces for interface discovery,
both incompatible or unmaintained under Python 3.13.

Switch to async-upnp-client which provides asyncio-native UPnP/IGD
support. Rewrite UPnPService.discover() to use async_search() for
device discovery and AiohttpSessionRequester for requests, removing
the blocking ThreadPoolExecutor path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@ping-ke ping-ke changed the title replace upnpclient with async-upnp-client for NAT port mapping p2p: replace upnpclient with async-upnp-client for NAT port mapping Mar 15, 2026
@ping-ke ping-ke changed the title p2p: replace upnpclient with async-upnp-client for NAT port mapping Part 6 - p2p: replace upnpclient with async-upnp-client for NAT port mapping Mar 15, 2026
@ping-ke ping-ke marked this pull request as draft March 17, 2026 06:43
ping-ke added 2 commits March 19, 2026 10:06
- Fix AttributeError: replace undefined self.external_port/self.internal_port/self.protocol with self.port
- Fix _discover timeout: wrap async_search with asyncio.wait_for instead of waiting after it completes
- Fix _run: guard _add_port_mapping with self._service check to avoid AttributeError
- Fix _delete_port_mapping: delete both TCP and UDP mappings to match AddPortMapping
- Remove dead code: _refresh_task and _running fields that were never used
@ping-ke ping-ke marked this pull request as ready for review March 19, 2026 02:33
@ping-ke ping-ke requested review from qizhou, qzhodl and syntrust March 19, 2026 02:33
Comment thread quarkchain/p2p/nat.py Outdated
Comment thread quarkchain/p2p/nat.py Outdated
Comment thread quarkchain/p2p/nat.py
Comment thread quarkchain/p2p/nat.py Outdated
ping-ke and others added 9 commits April 3, 2026 18:09
upnpclient uses synchronous I/O and netifaces for interface discovery,
both incompatible or unmaintained under Python 3.13.

Switch to async-upnp-client which provides asyncio-native UPnP/IGD
support. Rewrite UPnPService.discover() to use async_search() for
device discovery and AiohttpSessionRequester for requests, removing
the blocking ThreadPoolExecutor path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix AttributeError: replace undefined self.external_port/self.internal_port/self.protocol with self.port
- Fix _discover timeout: wrap async_search with asyncio.wait_for instead of waiting after it completes
- Fix _run: guard _add_port_mapping with self._service check to avoid AttributeError
- Fix _delete_port_mapping: delete both TCP and UDP mappings to match AddPortMapping
- Remove dead code: _refresh_task and _running fields that were never used
@ping-ke ping-ke changed the base branch from upgrade/py313-baseline to master April 4, 2026 16:06
@ping-ke ping-ke changed the base branch from master to upgrade/py313-baseline April 4, 2026 16:06
ping-ke added 3 commits April 6, 2026 16:52
- extract shared socket/aiohttp mocks into fixtures
- extract fake_wait helper to reduce duplication
- add tests for edge cases: exception handling, missing service, session cleanup
- fix coroutine never awaited warning in test_run_exits_on_cancel
@ping-ke ping-ke requested a review from qzhodl April 6, 2026 10:02
Comment thread quarkchain/p2p/nat.py
Comment thread quarkchain/p2p/tests/test_nat.py Outdated
ping-ke added 2 commits April 11, 2026 23:50
…andling

- Override _cleanup() so stop() is called on service shutdown, preventing
  port mapping and aiohttp session leaks when the node exits
- Close any existing session before creating a new one in discover() to
  avoid orphaned sessions on repeated calls
- Handle SOAP error 718 (ConflictInMappingEntry) as success, matching the
  previous best-effort behavior from master
- Roll back partial port mappings if AddPortMapping fails mid-loop
- Import UpnpActionResponseError for typed SOAP error handling
Add _discover_lock so that concurrent callers cannot race on _session
and _service state. The lock wraps the entire discover() body, ensuring
each call fully completes (including session cleanup in the finally block)
before the next begins.
… fix bool type, deduplicate

- Guard on_response with early return if _service already found (SSDP
  sends one response per device sub-type, so the callback fires ~9x)
- Extract location from CaseInsensitiveDict headers (newer
  async_upnp_client passes headers dict, not a response object)
- Recursively traverse embedded_devices to find WANIPConnection service
  (root device only exposes Layer3Forwarding; WANIPConnection lives in
  the InternetGatewayDevice → WANDevice → WANConnectionDevice subtree)
- Pass NewEnabled=True (bool) instead of 1; async_upnp_client validates
  the UPnP boolean type strictly
Comment thread quarkchain/p2p/nat.py Outdated
Comment thread quarkchain/p2p/nat.py Outdated
ping-ke added 2 commits April 14, 2026 23:31
Use asyncio.Event + wait_for so _discover returns as soon as the first
WANIPConn service is found instead of waiting the full 30-second timeout.
Add post-await re-check to guard against concurrent on_response tasks
that all pass the initial self._service guard before any of them completes.
Connect the UDP socket to the UPnP router's IP (parsed from the SSDP
location URL and stored in _router_host) instead of 8.8.8.8, so the OS
routing table selects the interface that actually reaches the router.
Matches the behaviour of the old netifaces-based
find_internal_ip_on_device_network() without the extra dependency.

Tests updated accordingly:
- test_get_internal_ip sets _router_host and asserts connect target
- Add test_get_internal_ip_no_router_host: returns None before discover()
- Add test_add_port_mapping_no_internal_ip: raises RuntimeError when
  _router_host is None
- Fix fake_search to pass dict response so response.get('location') works
- Set fake_device.embedded_devices={} to prevent MagicMock recursion
- Move MOCK_ROUTER_HOST to top constants with clarifying comments
- Remove trivial guard tests (no_service, already_none, exits_on_cancel)
@ping-ke ping-ke requested a review from qzhodl April 15, 2026 06:16
Comment thread quarkchain/p2p/nat.py
@ping-ke ping-ke requested a review from qzhodl April 17, 2026 01:51
Comment thread quarkchain/p2p/nat.py
Comment thread quarkchain/p2p/tests/test_nat.py Outdated
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.

3 participants