Skip to content

v0.3.3

Choose a tag to compare

@github-actions github-actions released this 03 Oct 06:35

Added

  • join_host(host, port=None) -- the inverse of normalize_host, and the
    direction everyone writes by hand and gets wrong on IPv6: join_host("::1", 8080) is '[::1]:8080', not '::1:8080'. Accepts a string, an address, an
    IPv4Interface/IPv6Interface or an Fqdn. Only an IPv6 literal is
    bracketed
    -- a hostname never is, because brackets in a URI authority assert
    that the inside is an address. A port-less v6 comes back bare, which is what
    makes normalize_host(join_host(h, p)) == (h, p) hold in every case. Three
    consuming projects were writing this themselves.

  • unmap(value) -- collapse an IPv4-mapped IPv6 address (::ffff:10.0.0.5)
    to plain IPv4; anything else passes through. Built on
    IPv6Address.ipv4_mapped, not a "::ffff:" prefix test. Measured: all of
    ::FFFF:10.0.0.5, ::ffff:0:1 and 0:0:0:0:0:ffff:0a00:0005 are mapped
    addresses that a startswith("::ffff:") and "." in text check leaves
    untouched. One address has many spellings; only the parsed form sees through
    them.

  • is_wildcard(value) -- whether a value means "every local address":
    "", None, "0.0.0.0", "::", and any other unspecified spelling. Strips
    a %zone. Never raises, so it stays usable in a branch without a guard.

  • disable_connreset(sock) and bind(..., connreset=False) -- stop
    Windows reporting an ICMP port-unreachable provoked by an earlier send as
    ConnectionResetError on a later, unrelated receive, which kills a server's
    receive loop over a packet some other host did not want.

    There is no stdlib route to this, which is why it belongs here. Measured on
    3.14: CPython exports no socket.SIO_UDP_CONNRESET on any version, and even
    given the documented value (0x9800000C) socket.ioctl whitelists commands
    and answers ValueError: invalid ioctl command. It goes through WSAIoctl by
    ctypes instead. The obvious getattr(socket, "SIO_UDP_CONNRESET", None)
    version is a silent no-op on every platform. Off by default, since the report
    is sometimes wanted -- a client talking to one peer learns the peer is gone.

  • set_buffer_size(sock, receive=None, send=None) -- grow
    SO_RCVBUF/SO_SNDBUF and return what was granted, read back with
    getsockopt rather than echoed from the request. The kernel is not obliged to
    agree and does not say so: setsockopt succeeds and then grants less, capped
    by net.core.rmem_max on Linux -- which also doubles the request, so a
    read-back above it is normal there. The silent partial grant is the failure
    mode. Only grows, so it cannot undo earlier tuning.

  • SocketOption(level, name, value) -- a named triple for bind's
    options=. A NamedTuple, so bare tuples keep working; purely so a list of
    them reads better than Iterable[Tuple[int, int, Any]].

  • MACAddress.hex(sep=None, bytes_per_sep=1) -- exactly bytes.hex, as a
    passthrough to .packed.hex. Present because this is a value object rather
    than a bytes subclass, so the method is not inherited -- and a downstream
    project was subclassing the type partly to add it back.

  • interface_enumerations() -> int -- how many times this process has
    really enumerated its adapters. Counts the syscall and never a cached hit,
    which is the number that matters once cache= makes a lookup and an
    enumeration different events: the enumeration is the one a packet flood
    multiplies. Worth exporting as a metric, and it is the assertion a test
    wants -- read it, do the work, expect +1. Both raw flags count into one
    total, and the cache is keyed by raw, so a process using both warms up
    twice. clear_interface_cache() does not advance it.

    Without it, a test checking the property cache= exists for had to
    monkeypatch a private name -- including this package's own tests, which now
    use the counter instead.

  • is_broadcast(address, interface=None, *, cache=False) -- the one
    per-packet entry point the enumeration cache had left out. Without an
    interface it consults every adapter's prefixes, since 10.0.0.255 is only a
    broadcast if something carries 10.0.0.0/24: measured 1.25 ms against
    0.004 ms
    when the interface is passed, and a server asking the question of
    every request pays that per packet. UdpEndpoint._is_repliable uses the
    shared cache now, so reply_socket() no longer enumerates per datagram when
    the arrival index did not resolve. Passing interface remains the fastest
    path and consults neither the cache nor the syscall.

  • supports_pktinfo(family=AF_INET) -- whether a UDP socket of that family
    can report each datagram's arrival interface on this host. The question a
    server asks before deciding how to bind: with packet info one wildcard
    socket serves every address and still knows which one a datagram reached,
    while without it the wildcard must be expanded into a socket per address --
    and on Linux that per-address socket receives no broadcasts at all.

    Answered by asking a socket, not by testing a constant's name:
    getattr(socket, "IP_PKTINFO", None) is None on CPython 3.9-3.11 on every
    platform, while the kernel supported it throughout, so a name test says "no"
    on a platform that works. Cached per family.

  • An opt-in cache for the adapter enumeration: get_interfaces(cache=...),
    and the same argument on interface_for, interfaces_for and
    is_local_address, plus clear_interface_cache() and
    INTERFACE_CACHE_TTL. cache=False is the default and changes nothing;
    cache=True uses the 1-second default TTL; a number is that TTL. cache=0 is
    a TTL of zero, so it enumerates and reseeds -- the whole of "force a refresh",
    which is why there is no second argument for it.

    Measured on a 7-adapter host: interface_for goes 0.98 ms to 0.010 ms
    (97x), and is_local_address likewise. The uncached call reaches 35-42 ms
    where there are many adapters, which is slow enough that a packet flood can
    deny service on its own -- an availability problem, not only a slow one.

    The default TTL is 1 second because this cache is for collapsing a burst
    of back-to-back calls, not for holding a snapshot: it bounds the cost at one
    syscall per second whatever the arrival rate, ~0.1% overhead at 1000 packets
    per second. UdpEndpoint's own arrival-interface cache now uses the same
    constant, down from a separate 30 s, so there is one number rather than two
    that can disagree.

    Prefer an event to a TTL where you have one -- cache=math.inf plus
    clear_interface_cache() at the moment the answer changes is strictly better
    than any TTL; a server has such a moment when it binds. A cached call
    returns fresh Interface objects
    : Interface is
    not frozen and .ips/.raw are mutable, so handing back the stored ones
    would let one caller corrupt every later caller's view. The copy is 0.004 ms
    against 0.969 ms to enumerate.

    The uncached path still calls get_interfaces() with no keyword argument,
    so an existing test double that takes none keeps working.

  • Backoff -- a retransmission timer: grows on loss, resets on
    progress
    . backoff_delays is a one-shot schedule for "retry this call a few
    times"; a long-lived session needs a current delay that advances on silence
    and returns to the base when the peer moves the transfer forward. Every
    protocol client in this family had written its own, so the shape is taken from
    the one already working rather than invented.

    .delay is stable between advance()/reset(), so arming a deadline,
    logging it and comparing against it all see one value -- a property that
    re-jittered per read would be a trap for exactly this code. Jitter is off by
    default
    , the opposite of backoff_delays: a point-to-point session
    retransmitting to one peer has no thundering herd to avoid, and TFTP and TCP
    both specify plain doubling. Two guard rails a hand-rolled version tends to
    miss: multiplier is floored at 1.0 so the timer can never shrink on
    repeated loss, and max_delay is floored at delay so a ceiling below the
    base cannot silently truncate the first wait.

  • backoff_delays(jitter_seconds=) and (symmetric=), also on retry --
    the two symmetric jitter shapes that protocol specifications require and
    the default cannot express, since it only ever shortens a delay:

    • jitter_seconds= is absolute and symmetric, uniform in
      [-jitter_seconds, +jitter_seconds] -- RFC 2131 §4.1 (DHCPv4),
      "randomized by the value of a uniform random number chosen from the range
      -1 to +1". The amplitude is capped at the current delay so the value cannot
      go negative before clamping; uncapped, a sub-second delay would pile much of
      its distribution on a single clamped value, which is neither uniform nor
      symmetric. At RFC 2131's own 4 s floor the cap never engages.
    • symmetric=True spreads the fractional jitter both ways -- RFC 8415
      §15
      (DHCPv6), RT = 2*RTprev + RAND*RTprev.

    In these modes max_delay caps the base, not the final value, so a delay
    may exceed it by up to the amplitude. Both RFCs require it: RFC 8415 applies
    its jitter after the cap (if RT > MRT: RT = MRT + RAND*MRT) and RFC 2131
    randomises around its 64 s maximum. Clamping was the obvious reading -- and
    the one the request suggested -- and it is wrong invisibly: measured, the
    spread at the cap became entirely negative with a mean of -0.024 instead of
    ~0, because every positive excursion was trimmed back. A backed-off client
    spends nearly all its time at the cap, so clamping would reintroduce exactly
    the synchronisation the mode is chosen to prevent. No mode returns a negative
    delay, and the default schedule is unchanged -- asserted against a copy of
    the previous implementation rather than against recorded numbers.

  • UdpEndpoint.reply_socket(datagram, port=...) accepts an iterable of
    ports
    , tried in order, for a server that pins transfer ports to a range a
    firewall can allow (tftp-hpa -R, dnsmasq --tftp-port-range). Materialised
    once, so a generator is safe but must be finite; empty raises ValueError.

  • UdpEndpoint.arecv() and .datagrams() -- recv() awaited, and an
    async for over arrivals. Same arguments, same Datagram, and pktinfo
    survives on every loop type
    , including the Windows default
    ProactorEventLoop.

    That default is the whole difficulty: it raises NotImplementedError from
    add_reader, and its own IocpProactor.recvfrom discards ancillary data. Nor
    is there an IOCP route -- measured on 3.14, CPython's _overlapped exposes no
    WSARecvMsg, so posting one through the loop's completion port would mean
    reimplementing the overlapped plumbing and calling the private
    IocpProactor._register. (asyncio.DatagramProtocol.datagram_received(data, addr) has no slot for cmsgs either, so a transport could not deliver them
    anyway.)

    So on such a loop a thread does the waiting -- and it reports readability
    only, never reads
    . The recv stays on the loop, which keeps bufsize a
    per-call argument and Datagram.truncated meaning what it means; a thread that
    read for you would have to fix bufsize when it started. It is created on the
    first await, joined on close(), and a test asserts no thread survives the
    endpoint. recv() is untouched.

    asyncio is imported lazily, never at package import time: pulling it into
    __init__ would force the very import ordering that produced this release's
    os.sysconf crash, and a consumer using only the value types should not pay for
    an event-loop import. A test spawns a fresh interpreter to assert
    'asyncio' not in sys.modules after import netimps.

  • UdpEndpoint.reply_socket(datagram) -- a socket bound so replies leave
    from the address the client addressed, which is the point of pktinfo in one
    call. A wildcard-bound server answering from a fresh socket sends from whatever
    the routing table prefers, and DHCP and TFTP clients both check and drop a
    reply from an address they never talked to. Demonstrated by the contrast: a
    plain wildcard reply to a client that addressed 127.0.0.2 comes from
    127.0.0.1.

    Three platform traps it absorbs, each measured rather than reasoned about:
    a v4 arrival on a dual-stack listener is ::ffff:a.b.c.d, and binding that
    needs IPV6_V6ONLY off which Windows does not default to, so it is unmapped
    and answered from an AF_INET socket; a broadcast, multicast or unspecified
    destination must not be answered from, and is classified and skipped rather
    than discovered by a failed bind, because Linux binds 255.255.255.255 and
    239.1.2.3 happily where Windows refuses them; and an IPv6 link-local
    destination needs the arrival ifindex as its scope, carried as a %zone
    suffix. Falls back to the endpoint's own address and then the wildcard, because
    a reply from the wrong address still beats no reply. connreset=False by
    default, inverted from bind(): a server loop must not die because an earlier
    answer drew an ICMP port-unreachable from a client that had gone.

  • is_broadcast(address, interface=None) -- whether an address is an IPv4
    broadcast, limited or subnet. The question a wildcard-bound server asks
    before answering: RFC 1123 says a TFTP server ignores a broadcast request, and
    DHCP must tell a broadcast DISCOVER from a unicast RENEW. 255.255.255.255
    needs no context; 10.0.0.255 is only a broadcast if some interface carries
    10.0.0.0/24, so this consults interface prefixes -- pass interface to check
    one adapter and skip the enumeration. A v4-mapped address is unmapped first.
    IPv6 has no broadcast, so a genuine v6 address is always False; is_multicast
    stays the separate companion, since one name meaning both would hide which
    matched.

  • An unbounded Interface.mtu is now a number, not None. The Windows
    loopback adapter reports 0xFFFFFFFF, which the code called an "unknown"
    sentinel and mapped to None. It is ULONG max and means unbounded -- there is
    no link to constrain loopback -- so that conflated "no limit" with "could not
    read", and cost callers in the one direction that matters: handling None by
    falling back to 1500 capped loopback at 1472 when it delivers 65507. Linux
    reports its own lo as 65536 rather than as nothing, so the two platforms
    disagreed about the same physical reality.

    Such an interface now reports 65535, the largest datagram the 16-bit IP
    total-length field can describe -- a clamp to reality rather than an invented
    figure. max_udp_payload() then yields exactly the 65507 measured to arrive,
    and a test moves a datagram that size to prove it. None now means only that
    the platform genuinely could not read an MTU.

  • max_udp_payload(mtu, ipv6=False) -- the largest UDP payload that fits an
    MTU without fragmenting (1472 for 1500, 1452 for v6). Takes an int rather
    than an Interface on purpose: Interface.mtu is Optional[int] and Windows
    reports no MTU for the loopback adapter
    , so whether to fall back to 1500 or
    refuse is the caller's decision. The v4 figure uses the minimum 20-byte header,
    so a packet carrying IP options can still fragment.

  • AddressInUseError(OSError) -- one stable type for "the address is
    taken", raised by bind() instead of whatever the platform happened to call
    it. Measured on Windows 11 ARM64 against an exclusive holder, the same
    situation
    had three shapes: PermissionError/errno 13 on 3.14,
    OSError/errno 10013 on 3.9, and OSError/errno 10048 without the takeover
    flag. The 3.14 row is the harmful one -- PermissionError says "privilege
    problem", and Windows has no privileged ports, so a consumer branching on the
    type sent its user after an elevation problem that cannot exist.

    errno is normalised to EADDRINUSE, bind_error_hint()'s text is the
    message, and the original is chained as __cause__ so winerror stays
    reachable. It subclasses OSError and deliberately not
    PermissionError: every except OSError keeps working while
    except PermissionError stops catching a case that was never about
    permission. A genuine POSIX EACCES on a port below 1024 is left exactly as
    it was.

  • Fqdn -- a domain name as a value type, with label algebra: .labels,
    .hostname, .domain, .domains, .tld, .is_fully_qualified(),
    .with_hostname(), .is_subdomain_of(), .relative_to(), .reverse(),
    .common_ancestor(), and / to compose. Immutable, hashable, ordered on
    reversed labels so sorting groups by TLD. Case-insensitive equality per
    RFC 4343, with __hash__ agreeing. .resolve(), .ping() and .ip()
    delegate to the existing module functions. is_valid/try_parse classmethods
    match MACAddress's shape, and FqdnLike is the accepted-input union.

    name in domain works like the stdlib's address in network --
    Fqdn("www.example.com") in Fqdn("example.com"). Inclusive, where
    .is_subdomain_of() is strict: a zone contains its own apex just as a /24
    contains its network address. Accepts a str, ignores qualification, and
    answers False rather than raising for anything unparseable. A label test
    is "com" in f.labels.

    Text interop: str(f) is the name, and f + str / str + f give a plain
    str, for building a URL or a log line. Fqdn + Fqdn raises and points at
    /.

    Also .unicode (the display form, decoding punycode -- labels are stored
    ASCII because that is what goes on the wire), .wire/.wire_length (the DNS
    wire encoding, delegated to the package's own encoder; .wire_length is what
    the 255-octet protocol limit applies to, against the 253 printable limit),
    .is_hostname() (RFC 1123 LDH, narrower than what the type accepts, since
    _dmarc and _sip._tcp are real names), and .is_wildcard (a predicate only
    -- no matches(), because DNS and TLS disagree about whether *.example.com
    covers a.b.example.com).

    The algebra is deliberately inverted from pathlib, because DNS puts the
    most significant label last: .name is the leftmost label, .parent strips
    the leftmost, and / prepends -- Fqdn("example.com") / "www" is
    www.example.com. DNS vocabulary is primary and the pathlib spelling is an
    alias on the same value (.domain/.parent, .hostname/.name,
    .labels/.parts). There is no .suffix alias for .tld: a filesystem
    suffix is part of a name while a TLD is a whole label.

    An address literal is refused -- Fqdn("10.0.0.1") raises -- because this
    is a name algebra and an IP has no labels, parent or TLD. The trailing dot is
    absoluteness and is part of identity, so Fqdn("example.com") != Fqdn("example.com."), as Path("a") != Path("/a"). .domain is not the
    registrable domain (example.com's is com): that needs the Public Suffix
    List, which would be a hard dependency, so the gap is documented instead.

  • Host.fqdn -- the bridge: an Fqdn when the host is a name, None when
    it is an address. Host stays the union type and is otherwise unchanged.

  • recvmsg() / sendmsg() now work on Windows, and are public on every
    platform, along with CMSG_LEN(), CMSG_SPACE() and supports_recvmsg().
    CPython ships neither method on Windows, so this binds WSARecvMsg and
    WSASendMsg through ctypes and delegates to CPython's own methods
    elsewhere. Signatures and return shapes are CPython's exactly, for both
    address families -- an AF_INET6 sender comes back as the usual
    (host, port, flowinfo, scope_id) 4-tuple, and a v6 destination accepts a
    2-, 3- or 4-tuple with an optional %zone suffix.

  • UdpEndpoint reports the arrival interface on Windows -- v4, v6 and
    dual-stack ::, on 3.9 through 3.14. supports_pktinfo and
    supports_src_pinning are no longer always False there. It calls the
    Winsock backend directly rather than the patched stdlib method, so disabling
    the patch below does not cost it pktinfo. One gap is deliberate: pinning by
    interface index alone raises ValueError on Windows, because that platform
    sends a zero source address literally -- a pin of 0.0.0.0 arrives from
    0.0.0.0 -- where Linux reads zero as "kernel chooses". Pass an
    address-bearing src.

  • A v4 arrival on an AF_INET6 endpoint now reports the documented v4-mapped
    address on every platform.
    Linux and macOS carry it in the v6 cmsg; Windows
    reports a plain v4 address at IPPROTO_IP and is the only platform that
    reports it at all, since its v6 option delivers no cmsg for a v4 arrival. That
    needed IP_PKTINFO set on the AF_INET6 socket as well, which macOS refuses
    outright and Linux accepts and does not need -- so it is set with the error
    ignored.

Changed

  • The patched sock.recvmsg now normalises the v4 IP_PKTINFO payload to the
    POSIX layout
    ; netimps.recvmsg() still reports the platform's own bytes, and
    UdpEndpoint is unaffected. On Windows the patched method rewrites
    {addr, ifindex} (8 bytes) into {ifindex, spec_dst, addr} (12 bytes) with
    spec_dst zero-filled -- byte-for-byte what macOS produces. The patched
    sendmsg accepts either layout, chosen by length.

    Rationale: installing the method name without the layout is a
    half-impersonation, and the missing half is what made POSIX-shaped code unpack
    =I4s4s from an 8-byte buffer and raise struct.error -- which is not an
    OSError, so it escaped receive handlers. spec_dst is zero rather than a copy
    of addr because the two genuinely differ for a broadcast (measured on Linux:
    the local interface address against 255.255.255.255), and code reads
    spec_dst to get the local address -- so copying addr would corrupt exactly
    the field it wanted. Zero is visibly wrong; 255.255.255.255 is not.

    Known consequence, not fixed by this. The patch is additive in names and
    therefore not in behaviour: code testing
    hasattr(socket.socket, "recvmsg") to detect POSIX now gets the POSIX answer
    on Windows. pydhcp 0.6.1 and earlier read ipi_spec_dst for their
    SERVER_IDENTIFIER, and measured, they receive but allocate and reply to
    nothing
    on Windows. A zero-filled spec_dst does not degrade a consumer that
    resolves its interface from that field, it silences it -- an earlier draft of
    this entry said "will see 0.0.0.0 ... and no longer a crash", which was
    inferred from the field's value rather than measured against the consumer. The
    owner's decision is to accept this: the two projects are released together and
    have no external consumers yet. UdpEndpoint is the supported way to obtain
    that address correctly on every platform; NETIMPS_NO_SOCKET_PATCH=1 opts out
    of the patch entirely.

  • bind() and normalize_host() now accept the package's usual loose union,
    not just a str: an address object, an IPv4Interface/IPv6Interface (its
    .ip is used), a Host or an Fqdn. bind() previously leaked a raw
    socket-layer TypeError -- "str, bytes or bytearray expected, not
    IPv4Address" -- not even a netimps error, for a value ping, resolve and
    UdpEndpoint.send all take; they coerce through a shared helper that these two
    simply never used. join_host, normalize_host's inverse, already accepted
    them. A network still raises TypeError, since it names no single host.

    normalize_host uses an allowlist, not a str() fallback. A fallback
    accepted None and turned it into the hostname "None" -- a plausible answer
    that is wrong and that a caller cannot detect -- and did the same for an int or
    a list.

Fixed

  • import netimps then import asyncio crashed on Windows. The import-time
    socket patch makes socket.socket.sendmsg exist, and CPython's
    asyncio/selector_events reads hasattr(socket.socket, 'sendmsg') at import
    time as a POSIX proxy, then calls os.sysconf('SC_IOV_MAX') guarding only
    except OSError -- so the AttributeError from a missing os.sysconf escaped
    and the import died. sendmsg and os.sysconf are both POSIX and had always
    travelled together; the patch was the first thing to separate them.

    The patch now installs an os.sysconf shim alongside, answering per name:
    SC_IOV_MAX returns 1024 (tunable with patch_socket_module(iov_max=...)) and
    every other name raises ValueError. Per name because the three stdlib callers
    guard differently and no single behaviour satisfies them -- asyncio catches
    OSError, concurrent.futures catches (AttributeError, ValueError),
    multiprocessing catches Exception; raising OSError for everything rescues
    asyncio and breaks ProcessPoolExecutor. Regression tests run in fresh
    interpreters
    , since in-process tests cannot see it: by the time a test body
    runs, asyncio is already imported.

  • sendmsg() failed on every connected stream socket on Windows.
    WSASendMsg refuses SOCK_STREAM outright with WSAEINVAL -- measured on
    Windows 11 build 28000, where a connected SOCK_DGRAM is accepted, so the
    refusal is about the socket type and not about being connected. The
    buffers-only path now uses WSASend, Windows' own scatter-gather send,
    which works there and showed no small IOV_MAX-like cap (500 buffers in one
    call, verified). A destination or a control buffer still routes through
    WSASendMsg, the only call that carries either, so UdpEndpoint.send(src=)
    is unchanged. Ancillary data on a stream socket still fails, which is correct:
    Windows has no per-packet information to attach to one.

  • UdpEndpoint reported nothing when a datagram was too large for
    bufsize.
    Datagram now carries truncated, from MSG_TRUNC, beside
    the existing control_truncated. The flag was always in msg_flags and was
    being dropped, so a caller had no way to tell a complete datagram from the
    leading fragment of a longer one -- and a protocol parser handed a message cut
    mid-field reports a malformed packet rather than a short read. Measured on
    Linux with bufsize=576: a 1102-octet datagram arrived cut to 576 with the
    flag set and discarded. Reported, not raised: deciding that a short datagram is
    fatal belongs to the protocol.

    It is reported on the no-pktinfo path too, which now goes through recvmsg
    with a zero-length control buffer rather than recvfrom, because recvfrom
    cannot report it. Losing the arrival interface is a documented degrade; losing
    this is silent data loss, and the two no longer have to be given up together.

  • A cancelled arecv() left its reader registered on the loop, and the
    notifier stayed bound to the first loop for good. Both reported by a consumer
    reading the code, and both reproduced here before fixing.

    Cancelling the awaiting task is the ordinary server shutdown -- cancel the
    receive task, then close the endpoint -- and the registration was removed only
    when it fired, so the loop was left watching a socket that then closed and
    raised from the selector on its next poll. Measured:
    loop.remove_reader(fileno) after a cancelled arecv returned True,
    meaning one was still there. A cancelled arecv now unregisters itself, and
    the thread path drops its pending future.

    Separately, the notifier thread captured its loop for its whole lifetime, so
    serving one endpoint from a second loop -- asyncio.run(serve()) twice, or a
    server stopped and restarted -- sent readiness to a closed loop. Measured on a
    ProactorEventLoop: the second exchange timed out while the daemon thread
    raised an unhandled "Event loop is closed" to stderr, where no caller could
    see it. A different running loop now retires the old thread and starts
    another, joining the old one rather than abandoning it; the only previous
    reset was close(), which also closes the socket. The post is additionally
    guarded, since a loop can close between the select and the call.

  • bind(options=[(SOL_SOCKET, SO_REUSEADDR, 1)]) failed with a bare
    WSAEINVAL on Windows
    -- a regression introduced in this same unreleased
    cycle, caught by a consumer's test suite. Windows refuses SO_REUSEADDR on a
    socket that already carries SO_EXCLUSIVEADDRUSE, and once bind() began
    setting the latter for both values of reuse_address, the stdlib-shaped
    spelling of "share this address" stopped working. The error names neither
    option, so the caller saw only "An invalid argument was supplied".

    An explicit nonzero SO_REUSEADDR in options= is now treated as
    allow_address_takeover=True -- it is the caller asking for takeover in so
    many words. A zero value stays an explicit opt-out. bind_error_hint learned
    WSAEINVAL, since a bare 10022 is undiagnosable. Verified against the
    reporting consumer's suite: the three tests that failed now pass, and fail
    again when the fix is reverted.

  • A v4 client of a dual-stack listener could not be replied to at all.
    Measured on Windows 11 ARM64 with an AF_INET client on 127.0.0.1 and a
    bind("::", family=AF_INET6, IPV6_V6ONLY=0) listener, it failed both ways:

    • With pktinfo, reply_socket correctly returned an AF_INET socket, but
      datagram.sender was still the v6 4-tuple -- so the reply.sendto(answer, packet.sender) shown in this library's own docs raised TypeError: AF_INET address must be a pair (host, port).
    • Without pktinfo there was no local_address, so both fallbacks used the
      listener's family; the resulting AF_INET6 socket carries
      IPV6_V6ONLY=1 on Windows (the platform default, which bind() does not
      clear), and sendto to a mapped address failed with WinError 10049. The
      exchange silently never started.

    Now the sender's family decides the reply socket's, with or without
    pktinfo, and the own-address fallback is skipped when it is in the wrong
    family. New Datagram.reply_address gives the peer in the family that was
    chosen -- a mapped sender as a plain (host, port), everything else unchanged
    -- and is what the examples now pass to sendto.

  • is_multicast missed a v4-mapped group below Python 3.13. The stdlib only
    began delegating a mapped address's is_* properties to the embedded v4
    address in 3.13, so IPv6Address("::ffff:224.0.0.1").is_multicast is False
    on 3.9 and True on 3.14 -- the function's answer depended on the
    interpreter. It unmaps first now, as is_broadcast already did.
    UdpEndpoint._is_repliable inherits this, so on 3.9-3.12 a reply socket could
    bind a mapped multicast destination.

  • reply_socket answered from the wrong address when the port was taken.
    The candidate loop caught every OSError and advanced the address, so an
    explicit port= already held on the arrival address fell through to the
    endpoint's own address and then the wildcard with the same port -- and
    where that later bind succeeded, the reply left from an address the client
    never addressed, which is the single failure this method exists to prevent.

    Measured on Windows 11 ARM64 against the pre-fix code: holding
    10.6.0.223:57014 and replying to a datagram that arrived there returned a
    socket bound to 127.0.0.1:57014.

    A held port and an unusable address are different failures and now move in
    different directions -- an unbindable address (broadcast, multicast, a wrong
    scope) advances the address, a held port advances the port on the same
    address. When the ports run out on an otherwise bindable address this raises
    AddressInUseError and binds nothing, so a caller can also tell "port
    busy, try another" from "this address is unbindable", which the old generic
    OSError hid. Both reply_socket and AddressInUseError are new in this
    unreleased cycle, so no released behaviour changed.

  • bind()'s default let a second live UDP socket take the port on Linux.
    reuse_address=True (the default) set SO_REUSEADDR, and on a datagram
    socket that is not the TIME_WAIT convenience it is for TCP -- it permits
    duplicate bindings of live sockets. Measured on WSL: a second bind() of
    the same live UDP addr:port with default arguments succeeded and the datagram
    went to the second socket, with the holder getting no error. socket(7) is
    explicit that the exception is an active listening socket, and a UDP socket
    never listens.

    SO_REUSEADDR is now set on POSIX for stream sockets only. Sharing a UDP
    port stays reachable through the names that say so -- reuse_port=True
    (SO_REUSEPORT, the option designed for it) or allow_address_takeover=True.
    multicast_socket sets what it needs itself and was verified not to rely on
    the old default.

    This also corrects two documentation passages that stated the opposite: both
    said "two live sockets still cannot hold one addr:port", which is true for
    TCP and false for UDP on Linux.

  • bind(reuse_address=False) left a Windows wildcard bind open to hijack.
    It set nothing -- both SO_REUSEADDR and SO_EXCLUSIVEADDRUSE read 0 --
    and Windows then lets a more specific SO_REUSEADDR bind take over a
    non-exclusive wildcard. Reproduced: a thief binding 127.0.0.1 received the
    datagram while the holder on 0.0.0.0 got nothing and no error.

    So the flag that reads as "strictest" was the least strict setting available,
    which is the worst shape a safety option can have -- the careful caller got
    the unsafe behaviour. SO_EXCLUSIVEADDRUSE is now set on Windows whenever
    allow_address_takeover is false, for both values of reuse_address;
    that option's only effect there is denying the takeover, so it costs nothing.
    reuse_address now governs POSIX SO_REUSEADDR only, which is what the name
    means everywhere else. allow_address_takeover=True remains the one way to
    opt into the old behaviour. Windows-only change, strictly toward safety.

  • UdpEndpoint.recv() enumerated every interface on every datagram.
    Measured on Windows loopback with 300-octet packets: 1.07 ms per packet
    against 0.015 ms with resolve_interface=False -- a 70x cost on the default
    path, and one a consuming project measured at 35-42 ms per enumeration on a
    host with more adapters. Worse than a bad default: the class docstring's own
    example uses the default and reads .interface, so the documented usage was
    the slow one, and a server loop is a hot loop by definition since the sender
    controls the rate.

    Now resolved through a per-endpoint index -> Interface cache, refreshed on a
    miss as well as on a 30-second TTL -- a miss means the adapter set changed,
    which is both cheap to act on and exactly when it matters. Negative results are
    cached too, so a stale or vanished index does not re-enumerate forever.
    Measured after: 0.017 ms per packet, and one enumeration for ten datagrams
    instead of ten.

  • UdpEndpoint.send()'s docstring still claimed Windows could not honour src
    at all and that supports_src_pinning was False there. Both stopped being
    true earlier in this release.

  • IPv6 UdpEndpoint was silently degraded on Windows. The receive option
    was looked up as IPV6_RECVPKTINFO, which Windows does not export at all;
    there IPV6_PKTINFO (19) is both the request and the carrier. The result was
    supports_pktinfo == False for every AF_INET6 endpoint on that platform
    while a raw recvmsg on the same socket delivered the cmsg perfectly well.
    The round-trip test passed throughout, because it took its own
    "no pktinfo here" early-exit branch, and its guard against exactly this
    consulted the same function that was wrong.

  • v4 pktinfo was silently off on Python 3.9 for Windows, which exports no
    socket.IP_PKTINFO even though Winsock supports it at the documented value
    19. Measured: 3.14 ran the v4 tests while 3.9 skipped nine of them and still
    reported a green suite. The documented literal is now used, with OSError
    from setsockopt as the real capability signal.

  • patch_socket_module() / socket_patched(), and netimps installs the
    patch on import by default: recvmsg/sendmsg onto socket.socket and
    CMSG_LEN/CMSG_SPACE onto the socket module, so POSIX-shaped code runs
    unchanged on Windows. Set NETIMPS_NO_SOCKET_PATCH=1 before the first import
    to decline, or call patch_socket_module(False) afterwards. It is strictly
    additive and never replaces a name the platform already provides, so on Linux
    and macOS it is a verified no-op. All four names are installed rather than
    just the method, because Windows lacks CMSG_SPACE too and the standard idiom
    is detect, then size a buffer, then receive.

    Cmsg payloads are not normalised to Linux's byte layouts. They genuinely
    differ -- a v4 IP_PKTINFO cmsg is type 8, 12 bytes,
    {ifindex; spec_dst; addr} on Linux against type 19, 8 bytes,
    {addr; ifindex} on Windows -- and faking one as the other would make
    correct-looking code read a plausible wrong address instead of failing.
    UdpEndpoint is the layer that handles the difference for you.

  • resolve_wire() -- a DNS client on the standard library alone: one
    question over UDP to each nameserver in turn, asked again over TCP when the
    reply is truncated, CNAME chains followed, records returned as native types
    like the other backends. It joins resolve()'s chain after dnspython,
    for an explicit ns= or source= only -- so a named nameserver now works
    without dnspython installed.

  • source= on resolve(), resolve_dnspython() and resolve_wire(): the
    local address the queries leave from (one per address family as a list).
    system and nslookup are skipped when it is set, as for ns=.

  • resolve_doh() -- DNS over HTTPS (RFC 8484), with an injectable fetch
    so a caller's own HTTP stack (proxy, CA bundle) carries the request.

Full Changelog: v0.3.2...v0.3.3