Skip to content

Defer AsyncZeroconf construction until the first .local lookup #98

@aiolibsbot

Description

@aiolibsbot

Summary

_AsyncMDNSResolverBase.__init__ eagerly constructs an owned AsyncZeroconf() on every resolver instance, even when the resolver only ever handles unicast names. Constructing AsyncZeroconf allocates zeroconf's network resources (multicast UDP sockets, the listener/engine thread). A resolver that never resolves a .local name pays that cost for nothing.

Proposing we defer the owned-instance construction to the first .local lookup that actually needs it.

Where

https://github.com/aio-libs/aiohttp-asyncmdnsresolver/blob/main/src/aiohttp_asyncmdnsresolver/_impl.py

def __init__(self, *args, async_zeroconf=None, mdns_timeout=DEFAULT_TIMEOUT, **kwargs):
    super().__init__(*args, **kwargs)
    self._mdns_timeout = mdns_timeout
    self._aiozc_owner = async_zeroconf is None
    self._aiozc = async_zeroconf or AsyncZeroconf()   # <-- eager

self._aiozc is only ever read in the .local branch of resolve() (load_from_cache, async_request). Unicast names go straight to super().resolve() and never touch it.

Proposed change

Keep _aiozc = async_zeroconf (so a caller-supplied instance is still used as-is) and create the owned instance on demand:

def _get_async_zeroconf(self) -> AsyncZeroconf:
    if self._aiozc is None:
        self._aiozc = AsyncZeroconf()
    return self._aiozc

Route the three self._aiozc.zeroconf reads through _get_async_zeroconf(). close() already guards on self._aiozc is not None, so a resolver that never created one closes as a clean no-op.

Concurrency is safe without a lock: AsyncZeroconf() is a synchronous constructor, so the None-check and the assignment run with no intervening await. Under asyncio's cooperative scheduling, two concurrent first-resolve() coroutines cannot interleave inside that block, so exactly one owned instance is ever created.

Tradeoffs (your call)

  1. Error timing changes. Any failure inside AsyncZeroconf() now surfaces at the first .local resolve() instead of at construction. For unicast-only resolvers it never surfaces at all. Arguably an improvement (construction no longer needs a running loop / network), but it is an observable behavior change.
  2. It changes a currently-tested invariant. test_async_context_manager_closes_resolver / ..._dual_resolver assert resolver._aiozc is not None right after construction. Those would move to "not None after the first .local resolve," plus a new test asserting a unicast-only resolver never constructs one.

Why I'm filing this rather than sending a PR

This trades a tested, intentional invariant for a resource win whose size depends on your real usage mix (how often a resolver handles unicast vs .local). If you think the deferral is worth it I'll send the PR with the test updates; if eager construction is deliberate, this is a no-op to close. Either way the decision is yours.


Filed by Kōan during an autonomous deep-work pass.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    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