feat(dns): add Client::fromSystem() to query the host's own resolver - #180
Open
loks0n wants to merge 2 commits into
Open
feat(dns): add Client::fromSystem() to query the host's own resolver#180loks0n wants to merge 2 commits into
loks0n wants to merge 2 commits into
Conversation
Client only took an explicit nameserver, so callers replacing PHP's dns_get_record() had nowhere to get the address the host actually resolves with. fromSystem() reads the first `nameserver` from /etc/resolv.conf, the same file libresolv reads; systemNameservers() returns every declared address for callers that want failover. The `search` list is parsed past, not applied. Expanding a bare name against it would let a public hostname resolve to an internal address, and under the ndots:5 Kubernetes sets by default it also costs several failed lookups before the real one. The UDP socket now takes its address family from the server rather than hardcoding AF_INET. The constructor has always accepted IPv6 servers via IP::ALL, but an AF_INET datagram socket cannot address one -- sendto() fails with "Unknown host" before anything leaves the box. Reachable as soon as a resolv.conf declares an IPv6 nameserver first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Benchmark resultsdns — transport throughput (4 cores, 250 repeats x 20 workers x 3 domains per transport)
Shared CI runners — treat absolute numbers as rough, compare modes within a run. Commit 5358749. |
fromSystem() reopened, reread and reparsed resolv.conf on every call, so a caller resolving in a loop paid it per query. Cache the parsed nameservers per path and check the file's size and modification time instead: 13.1us down to 0.9us per call, and a rewritten resolv.conf is still picked up. Size is in the signature alongside the modification time because second-resolution mtime cannot separate two writes within the same second. clearstatcache() is load-bearing and not covered by the suite. PHP's stat cache holds one path at a time, so an entry only goes stale for a caller stat-ing the same path in a tight loop with nothing in between -- the exact shape this cache creates. Any intervening stat in a test evicts the entry and hides it. Measured directly instead: with a resolv.conf rewritten by a separate process, filesize() returned the pre-write 19 bytes without the call and the correct 38 with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Clientonly accepted an explicit nameserver address, so a caller replacing PHP'sdns_get_record()had nowhere to get the address the host actually resolves with. This adds:Client::fromSystem(string $path = '/etc/resolv.conf', int $port = 53, int $timeout = 5, bool $useTcp = false): self— a client pointed at the first declared nameserver.Client::systemNameservers(string $path = ...): list<string>— every declared address, in file order, for callers that want to fall back to the next one on timeout.Client::getServer(): string— so the selected resolver is observable.Both read
/etc/resolv.conf, the same file libresolv reads. Both take a path, which keeps them testable and works where the file lives elsewhere.Reading the system resolver configuration is what libresolv, c-ares, Go's
net.Resolverand dnspython all do. This follows ReactPHP's split instead of the implicit versions: configuration discovery stays an explicit call rather than blocking file I/O hidden in a constructor, which matters because callers construct a client per query.The search list is deliberately not applied
Expanding a bare name against
searchwould let a public hostname resolve to an internal address — the opposite of what a caller vetting hostnames wants. Under thendots:5that Kubernetes sets by default it also costs several failed lookups before the real one. Documented in the docblock and the README rather than left as an omission.Also: the UDP socket now follows the server's address family
socket_create()hardcodedAF_INET, while the constructor has always accepted IPv6 servers throughIP::ALL. AnAF_INETdatagram socket cannot address an IPv6 nameserver —sendto()fails withUnknown hostbefore anything leaves the machine.This was latent before, but
fromSystem()makes it reachable as soon as aresolv.confdeclares an IPv6 nameserver first, so the factory would otherwise ship a footgun.Testing
bin/monorepo test dns— 186 unit and 34 e2e tests pass.bin/monorepo check dnsandbin/monorepo validateare clean, as is Vale on the README.Each of the five production hunks was reverted in turn to confirm a test actually fails for it:
testSendsToAnIpv6NameserverRatherThanFailingOnTheWrongFamilytestIgnoresCommentsAndOtherDirectivestestDoesNotTreatABareNameserverLineAsAnAddresstestSkipsMalformedAddressesButKeepsTheResttestDeduplicatesRepeatedNameserversThe IPv6 test binds a real IPv6 loopback port and asserts the query fails on the read rather than the send, and skips where IPv6 loopback is unavailable. An earlier version that only asserted
getServer()passed with the bug still present, sincesocket_create()never inspects the destination.Background
This comes out of replacing
dns_get_record()in a Swoole worker. UnderSWOOLE_HOOK_ALL, Swoole routesdns_get_record()throughswoole_get_default_remote_object_client(), which returns a newRemoteObject\Clienton every call and files it into a static array with no eviction — roughly 140 KB retained per call, plus a 129-process helper pool on first use.Utopia\DNS\Clientover hooked ext-sockets avoids both: measured at 0 bytes per call over 300 resolutions, with results matchingdns_get_record()on multi-record hosts, CNAME chains,localhostand NXDOMAIN.Reaching for it there meant hardcoding a nameserver, which is what this fills in.