Skip to content

feat(dns): add Client::fromSystem() to query the host's own resolver - #180

Open
loks0n wants to merge 2 commits into
mainfrom
feat/dns-client-from-system
Open

feat(dns): add Client::fromSystem() to query the host's own resolver#180
loks0n wants to merge 2 commits into
mainfrom
feat/dns-client-from-system

Conversation

@loks0n

@loks0n loks0n commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

What

Client only accepted an explicit nameserver address, so a caller replacing PHP's dns_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.Resolver and 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 search would let a public hostname resolve to an internal address — the opposite of what a caller vetting hostnames wants. Under the ndots:5 that 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() hardcoded AF_INET, while the constructor has always accepted IPv6 servers through IP::ALL. An AF_INET datagram socket cannot address an IPv6 nameserver — sendto() fails with Unknown host before anything leaves the machine.

This was latent before, but fromSystem() makes it reachable as soon as a resolv.conf declares 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 dns and bin/monorepo validate are 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:

Reverted Failing test
IPv6 socket family testSendsToAnIpv6NameserverRatherThanFailingOnTheWrongFamily
Comment stripping testIgnoresCommentsAndOtherDirectives
Field-count guard testDoesNotTreatABareNameserverLineAsAnAddress
Address validation testSkipsMalformedAddressesButKeepsTheRest
Deduplication testDeduplicatesRepeatedNameservers

The 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, since socket_create() never inspects the destination.

Background

This comes out of replacing dns_get_record() in a Swoole worker. Under SWOOLE_HOOK_ALL, Swoole routes dns_get_record() through swoole_get_default_remote_object_client(), which returns a new RemoteObject\Client on 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\Client over hooked ext-sockets avoids both: measured at 0 bytes per call over 300 resolutions, with results matching dns_get_record() on multi-record hosts, CNAME chains, localhost and NXDOMAIN.

Reaching for it there meant hardcoding a nameserver, which is what this fills in.

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>
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

Benchmark results

dns — transport throughput (4 cores, 250 repeats x 20 workers x 3 domains per transport)

transport req/s ok p50 ms p95 p99 max
udp 18417.6 15000/15000 1 1 1 3
tcp 17359.06 15000/15000 1 1 1 2
doh 10653.08 15000/15000 1 3 4 8

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>
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.

1 participant