Repository navigation
Only report sockets to c-ares when the OS says they are ready - #93
Open
ravindudanthanarayana wants to merge 1 commit into
Open
ravindudanthanarayana wants to merge 1 commit into
ravindudanthanarayana wants to merge 1 commit into
Conversation
Motivation: The c-ares poll loop passed every registered socket to `ares_process_fd` every 10ms without checking whether it was ready. c-ares takes a writable TCP socket to mean that `connect` finished, so it marked the connection as connected and sent on it. If the handshake was still in progress, which is the case for any server more than a poll interval away, that `send` fails with `ENOTCONN` on Darwin and c-ares gave up on the connection. TCP queries therefore failed with a spurious `connectionRefused` after a few milliseconds. This affects `USEVC` and the TCP fallback for truncated UDP responses. Modifications: - Add `SocketRegistry.readySockets(in:)`, which uses a non-blocking `poll` (`WSAPoll` on Windows) to narrow sockets to what is actually ready. `POLLERR`/`POLLHUP` count as ready so c-ares still sees failed connections. - Have `QueryProcessor.poll()` report only ready sockets, and call `ares_process_fd` once with no sockets so that timeouts still fire when nothing is ready. Everything now happens under the channel lock so a socket can't be closed and reused between checking and reporting it. - Add tests that don't use public DNS servers: a loopback DNS server for established and failed connections, and TEST-NET-1 for handshakes in progress. Result: TCP queries wait for the handshake and then succeed, or end in `timeout` if it never finishes, instead of failing with `connectionRefused`.
ravindudanthanarayana
marked this pull request as ready for review
November 1, 2026 11:16
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.
Motivation
On macOS, every
CAresDNSResolverquery over TCP to a server more than a few milliseconds away fails with a spuriousconnection refused. That covers.USEVCand, because it goes through the same path, the TCP fallback for truncated UDP responses. The same server answers fine over UDP:The error is misleading: no server refused anything, and in the examples above no TCP handshake had time to finish.
Root cause.
QueryProcessor.poll()passes every socket registered throughsock_state_cbtoares_process_fdevery 10ms, without checking that it's ready.sock_state_cbonly reports what c-ares is interested in, whileares_process_fdmeans the socket is ready. For a TCP socket that is still connecting:process_writetakes that to mean the connection is established, setsARES_CONN_STATE_CONNECTEDand callsares_conn_flush.sendon a socket inSYN_SENTfails withENOTCONNon Darwin (checked directly: errno 57).ares_conn_flushmaps any write error other than would-block toARES_ECONNREFUSED, and the connection is torn down.So the query only survives if the handshake beats the first poll, which is why this doesn't show up on loopback and nothing in the existing tests caught it.
Modifications
SocketRegistry.readySockets(in:)narrows sockets to what the OS reports as ready, using a non-blockingpoll(WSAPollon Windows).POLLERR/POLLHUPcount as ready for whatever c-ares was waiting on, so failed connections still reach it.QueryProcessor.poll()reports only ready sockets, then callsares_process_fdonce withARES_SOCKET_BADfor both, because timeouts must fire even when nothing is ready. An idle resolver still doesn't call into c-ares.No public API changes.
Result
TCP queries wait for the handshake, then succeed, or end in
.timeoutif it never finishes:Callers that matched on
.connectionRefusedfor a server that doesn't respond over TCP will now see.timeout, which is what that case always should have been. A connection that is really refused still reports.connectionRefused, and a test covers that.Test Plan
Added
CAresSocketReadinessTests, which uses no public DNS servers. Handshakes finish too quickly to observe on loopback (and on macOS a full listen backlog resets connections rather than holding them), so in-progress handshakes use TEST-NET-1 (192.0.2.1), asCAresCancellationTestsalready does. These tests skip viaXCTSkipon a network that rejects or intercepts that connection, so they can't fail because of the environment. Everything else runs against a small DNS server on loopback.test_tcpQuery_whileHandshakeIsInProgress_timesOuttest_tcpQuery_whileHandshakeIsInProgress_cancellationIsIsolatedtest_readySockets_doesNotReportSocketThatIsStillConnectingtest_readySockets_reportsOnlyWhatIsReadytest_tcpQuery_isAnsweredtest_concurrentQueries_areAllAnswered(50 each over UDP and TCP)test_udpQuery_truncatedResponse_fallsBackToTCPtest_tcpQuery_connectionRefused_failsWithoutWaitingForTimeoutThe "without" column comes from reverting
DNSResolver_c-ares.swiftwhile keeping the tests. The concurrent test was added after that run, so it has no "without" result.Run locally on macOS 27 (arm64), Swift 6.4:
swift test: 46 tests, 0 failures, 0 skipped. Note that the existingCAresDNSResolverTestsuse public DNS.CAresSocketReadinessTests,CAresCancellationTestsandCAresSynchronousFailureTestsrepeated 10 times: 0 failures.swift build --build-tests -Xswiftc -warnings-as-errorsandswift build -c release -Xswiftc -warnings-as-errors: clean.swift format lint --strict: clean.Not verified, please look at CI for these:
WSAPollpath. CI is the first time that code gets compiled.sendon a connecting non-blocking socket should returnEAGAINthere, so the regression tests would pass with or without the fix. Windows may be affected, since it hasWSAENOTCONNand c-ares is its default backend. I haven't confirmed either.connection refused: (CAresError(code: 11))symptom, but against an older c-ares, and I can't reproduce that dual-stack setup. This may be related, but I'm not claiming it fixes that issue.