Repository navigation
v0.3.3
Added
-
join_host(host, port=None)-- the inverse ofnormalize_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/IPv6Interfaceor anFqdn. 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
makesnormalize_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:1and0:0:0:0:0:ffff:0a00:0005are mapped
addresses that astartswith("::ffff:") and "." in textcheck 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)andbind(..., connreset=False)-- stop
Windows reporting an ICMP port-unreachable provoked by an earlier send as
ConnectionResetErroron 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 nosocket.SIO_UDP_CONNRESETon any version, and even
given the documented value (0x9800000C)socket.ioctlwhitelists commands
and answersValueError: invalid ioctl command. It goes throughWSAIoctlby
ctypesinstead. The obviousgetattr(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_SNDBUFand return what was granted, read back with
getsockoptrather than echoed from the request. The kernel is not obliged to
agree and does not say so:setsockoptsucceeds and then grants less, capped
bynet.core.rmem_maxon 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 forbind's
options=. ANamedTuple, so bare tuples keep working; purely so a list of
them reads better thanIterable[Tuple[int, int, Any]]. -
MACAddress.hex(sep=None, bytes_per_sep=1)-- exactlybytes.hex, as a
passthrough to.packed.hex. Present because this is a value object rather
than abytessubclass, 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 oncecache=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. Bothrawflags count into one
total, and the cache is keyed byraw, 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
interfaceit consults every adapter's prefixes, since10.0.0.255is only a
broadcast if something carries10.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_repliableuses the
shared cache now, soreply_socket()no longer enumerates per datagram when
the arrival index did not resolve. Passinginterfaceremains 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)isNoneon 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 oninterface_for,interfaces_forand
is_local_address, plusclear_interface_cache()and
INTERFACE_CACHE_TTL.cache=Falseis the default and changes nothing;
cache=Trueuses the 1-second default TTL; a number is that TTL.cache=0is
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_forgoes 0.98 ms to 0.010 ms
(97x), andis_local_addresslikewise. 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.infplus
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 freshInterfaceobjects:Interfaceis
not frozen and.ips/.raware 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_delaysis 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..delayis stable betweenadvance()/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 ofbackoff_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:multiplieris floored at1.0so the timer can never shrink on
repeated loss, andmax_delayis floored atdelayso a ceiling below the
base cannot silently truncate the first wait. -
backoff_delays(jitter_seconds=)and(symmetric=), also onretry--
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=Truespreads the fractionaljitterboth ways -- RFC 8415
§15 (DHCPv6),RT = 2*RTprev + RAND*RTprev.
In these modes
max_delaycaps 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 raisesValueError. -
UdpEndpoint.arecv()and.datagrams()--recv()awaited, and an
async forover arrivals. Same arguments, sameDatagram, and pktinfo
survives on every loop type, including the Windows default
ProactorEventLoop.That default is the whole difficulty: it raises
NotImplementedErrorfrom
add_reader, and its ownIocpProactor.recvfromdiscards ancillary data. Nor
is there an IOCP route -- measured on 3.14, CPython's_overlappedexposes 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. Therecvstays on the loop, which keepsbufsizea
per-call argument andDatagram.truncatedmeaning what it means; a thread that
read for you would have to fixbufsizewhen it started. It is created on the
firstawait, joined onclose(), and a test asserts no thread survives the
endpoint.recv()is untouched.asynciois imported lazily, never at package import time: pulling it into
__init__would force the very import ordering that produced this release's
os.sysconfcrash, 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.modulesafterimport 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 addressed127.0.0.2comes 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
needsIPV6_V6ONLYoff which Windows does not default to, so it is unmapped
and answered from anAF_INETsocket; 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 binds255.255.255.255and
239.1.2.3happily where Windows refuses them; and an IPv6 link-local
destination needs the arrivalifindexas 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=Falseby
default, inverted frombind(): 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.255is only a broadcast if some interface carries
10.0.0.0/24, so this consults interface prefixes -- passinterfaceto check
one adapter and skip the enumeration. A v4-mapped address is unmapped first.
IPv6 has no broadcast, so a genuine v6 address is alwaysFalse;is_multicast
stays the separate companion, since one name meaning both would hide which
matched. -
An unbounded
Interface.mtuis now a number, notNone. The Windows
loopback adapter reports0xFFFFFFFF, which the code called an "unknown"
sentinel and mapped toNone. 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: handlingNoneby
falling back to 1500 capped loopback at 1472 when it delivers 65507. Linux
reports its ownloas 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.Nonenow 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 (1472for 1500,1452for v6). Takes anintrather
than anInterfaceon purpose:Interface.mtuisOptional[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 bybind()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, andOSError/errno 10048 without the takeover
flag. The 3.14 row is the harmful one --PermissionErrorsays "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.errnois normalised toEADDRINUSE,bind_error_hint()'s text is the
message, and the original is chained as__cause__sowinerrorstays
reachable. It subclassesOSErrorand deliberately not
PermissionError: everyexcept OSErrorkeeps working while
except PermissionErrorstops catching a case that was never about
permission. A genuine POSIXEACCESon 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_parseclassmethods
matchMACAddress's shape, andFqdnLikeis the accepted-input union.name in domainworks like the stdlib'saddress 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 astr, ignores qualification, and
answersFalserather than raising for anything unparseable. A label test
is"com" in f.labels.Text interop:
str(f)is the name, andf + str/str + fgive a plain
str, for building a URL or a log line.Fqdn + Fqdnraises 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_lengthis 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
_dmarcand_sip._tcpare real names), and.is_wildcard(a predicate only
-- nomatches(), because DNS and TLS disagree about whether*.example.com
coversa.b.example.com).The algebra is deliberately inverted from
pathlib, because DNS puts the
most significant label last:.nameis the leftmost label,.parentstrips
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.suffixalias 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, soFqdn("example.com") != Fqdn("example.com."), asPath("a") != Path("/a")..domainis not the
registrable domain (example.com's iscom): that needs the Public Suffix
List, which would be a hard dependency, so the gap is documented instead. -
Host.fqdn-- the bridge: anFqdnwhen the host is a name,Nonewhen
it is an address.Hoststays the union type and is otherwise unchanged. -
recvmsg()/sendmsg()now work on Windows, and are public on every
platform, along withCMSG_LEN(),CMSG_SPACE()andsupports_recvmsg().
CPython ships neither method on Windows, so this bindsWSARecvMsgand
WSASendMsgthroughctypesand delegates to CPython's own methods
elsewhere. Signatures and return shapes are CPython's exactly, for both
address families -- anAF_INET6sender 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%zonesuffix. -
UdpEndpointreports the arrival interface on Windows -- v4, v6 and
dual-stack::, on 3.9 through 3.14.supports_pktinfoand
supports_src_pinningare no longer alwaysFalsethere. 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 raisesValueErroron Windows, because that platform
sends a zero source address literally -- a pin of0.0.0.0arrives from
0.0.0.0-- where Linux reads zero as "kernel chooses". Pass an
address-bearingsrc. -
A v4 arrival on an
AF_INET6endpoint 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 atIPPROTO_IPand is the only platform that
reports it at all, since its v6 option delivers no cmsg for a v4 arrival. That
neededIP_PKTINFOset on theAF_INET6socket 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.recvmsgnow normalises the v4IP_PKTINFOpayload to the
POSIX layout;netimps.recvmsg()still reports the platform's own bytes, and
UdpEndpointis unaffected. On Windows the patched method rewrites
{addr, ifindex}(8 bytes) into{ifindex, spec_dst, addr}(12 bytes) with
spec_dstzero-filled -- byte-for-byte what macOS produces. The patched
sendmsgaccepts 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
=I4s4sfrom an 8-byte buffer and raisestruct.error-- which is not an
OSError, so it escaped receive handlers.spec_dstis zero rather than a copy
ofaddrbecause the two genuinely differ for a broadcast (measured on Linux:
the local interface address against255.255.255.255), and code reads
spec_dstto get the local address -- so copyingaddrwould corrupt exactly
the field it wanted. Zero is visibly wrong;255.255.255.255is 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 readipi_spec_dstfor their
SERVER_IDENTIFIER, and measured, they receive but allocate and reply to
nothing on Windows. A zero-filledspec_dstdoes not degrade a consumer that
resolves its interface from that field, it silences it -- an earlier draft of
this entry said "will see0.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.UdpEndpointis the supported way to obtain
that address correctly on every platform;NETIMPS_NO_SOCKET_PATCH=1opts out
of the patch entirely. -
bind()andnormalize_host()now accept the package's usual loose union,
not just astr: an address object, anIPv4Interface/IPv6Interface(its
.ipis used), aHostor anFqdn.bind()previously leaked a raw
socket-layerTypeError-- "str, bytes or bytearray expected, not
IPv4Address" -- not even a netimps error, for a valueping,resolveand
UdpEndpoint.sendall 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 raisesTypeError, since it names no single host.normalize_hostuses an allowlist, not astr()fallback. A fallback
acceptedNoneand 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 netimpsthenimport asynciocrashed on Windows. The import-time
socket patch makessocket.socket.sendmsgexist, and CPython's
asyncio/selector_eventsreadshasattr(socket.socket, 'sendmsg')at import
time as a POSIX proxy, then callsos.sysconf('SC_IOV_MAX')guarding only
except OSError-- so theAttributeErrorfrom a missingos.sysconfescaped
and the import died.sendmsgandos.sysconfare both POSIX and had always
travelled together; the patch was the first thing to separate them.The patch now installs an
os.sysconfshim alongside, answering per name:
SC_IOV_MAXreturns 1024 (tunable withpatch_socket_module(iov_max=...)) and
every other name raisesValueError. Per name because the three stdlib callers
guard differently and no single behaviour satisfies them --asynciocatches
OSError,concurrent.futurescatches(AttributeError, ValueError),
multiprocessingcatchesException; raisingOSErrorfor everything rescues
asyncio and breaksProcessPoolExecutor. 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.
WSASendMsgrefusesSOCK_STREAMoutright withWSAEINVAL-- measured on
Windows 11 build 28000, where a connectedSOCK_DGRAMis accepted, so the
refusal is about the socket type and not about being connected. The
buffers-only path now usesWSASend, Windows' own scatter-gather send,
which works there and showed no smallIOV_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, soUdpEndpoint.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. -
UdpEndpointreported nothing when a datagram was too large for
bufsize.Datagramnow carriestruncated, fromMSG_TRUNC, beside
the existingcontrol_truncated. The flag was always inmsg_flagsand 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 withbufsize=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 thanrecvfrom, becauserecvfrom
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 cancelledarecvreturnedTrue,
meaning one was still there. A cancelledarecvnow 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 wasclose(), 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
WSAEINVALon Windows -- a regression introduced in this same unreleased
cycle, caught by a consumer's test suite. Windows refusesSO_REUSEADDRon a
socket that already carriesSO_EXCLUSIVEADDRUSE, and oncebind()began
setting the latter for both values ofreuse_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_REUSEADDRinoptions=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_hintlearned
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 anAF_INETclient on127.0.0.1and a
bind("::", family=AF_INET6, IPV6_V6ONLY=0)listener, it failed both ways:- With pktinfo,
reply_socketcorrectly returned anAF_INETsocket, but
datagram.senderwas still the v6 4-tuple -- so thereply.sendto(answer, packet.sender)shown in this library's own docs raisedTypeError: 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 resultingAF_INET6socket carries
IPV6_V6ONLY=1on Windows (the platform default, whichbind()does not
clear), andsendtoto a mapped address failed withWinError 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. NewDatagram.reply_addressgives 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 tosendto. - With pktinfo,
-
is_multicastmissed a v4-mapped group below Python 3.13. The stdlib only
began delegating a mapped address'sis_*properties to the embedded v4
address in 3.13, soIPv6Address("::ffff:224.0.0.1").is_multicastisFalse
on 3.9 andTrueon 3.14 -- the function's answer depended on the
interpreter. It unmaps first now, asis_broadcastalready did.
UdpEndpoint._is_repliableinherits this, so on 3.9-3.12 a reply socket could
bind a mapped multicast destination. -
reply_socketanswered from the wrong address when the port was taken.
The candidate loop caught everyOSErrorand advanced the address, so an
explicitport=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:57014and replying to a datagram that arrived there returned a
socket bound to127.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
AddressInUseErrorand binds nothing, so a caller can also tell "port
busy, try another" from "this address is unbindable", which the old generic
OSErrorhid. Bothreply_socketandAddressInUseErrorare 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) setSO_REUSEADDR, and on a datagram
socket that is not theTIME_WAITconvenience it is for TCP -- it permits
duplicate bindings of live sockets. Measured on WSL: a secondbind()of
the same live UDPaddr:portwith 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_REUSEADDRis 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) orallow_address_takeover=True.
multicast_socketsets 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 oneaddr: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 -- bothSO_REUSEADDRandSO_EXCLUSIVEADDRUSEread 0 --
and Windows then lets a more specificSO_REUSEADDRbind take over a
non-exclusive wildcard. Reproduced: a thief binding127.0.0.1received the
datagram while the holder on0.0.0.0got 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_EXCLUSIVEADDRUSEis now set on Windows whenever
allow_address_takeoveris false, for both values ofreuse_address;
that option's only effect there is denying the takeover, so it costs nothing.
reuse_addressnow governs POSIXSO_REUSEADDRonly, which is what the name
means everywhere else.allow_address_takeover=Trueremains 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 withresolve_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 -> Interfacecache, 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 honoursrc
at all and thatsupports_src_pinningwasFalsethere. Both stopped being
true earlier in this release. -
IPv6
UdpEndpointwas silently degraded on Windows. The receive option
was looked up asIPV6_RECVPKTINFO, which Windows does not export at all;
thereIPV6_PKTINFO(19) is both the request and the carrier. The result was
supports_pktinfo == Falsefor everyAF_INET6endpoint on that platform
while a rawrecvmsgon 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_PKTINFOeven 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, withOSError
fromsetsockoptas the real capability signal. -
patch_socket_module()/socket_patched(), and netimps installs the
patch on import by default:recvmsg/sendmsgontosocket.socketand
CMSG_LEN/CMSG_SPACEonto thesocketmodule, so POSIX-shaped code runs
unchanged on Windows. SetNETIMPS_NO_SOCKET_PATCH=1before the first import
to decline, or callpatch_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 lacksCMSG_SPACEtoo 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 v4IP_PKTINFOcmsg 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.
UdpEndpointis 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 joinsresolve()'s chain afterdnspython,
for an explicitns=orsource=only -- so a named nameserver now works
withoutdnspythoninstalled. -
source=onresolve(),resolve_dnspython()andresolve_wire(): the
local address the queries leave from (one per address family as a list).
systemandnslookupare skipped when it is set, as forns=. -
resolve_doh()-- DNS over HTTPS (RFC 8484), with an injectablefetch
so a caller's own HTTP stack (proxy, CA bundle) carries the request.
Full Changelog: v0.3.2...v0.3.3