Skip to content

Only report sockets to c-ares when the OS says they are ready - #93

Open
ravindudanthanarayana wants to merge 1 commit into
apple:mainfrom
ravindudanthanarayana:fix/cares-tcp-socket-readiness
Open

ravindudanthanarayana wants to merge 1 commit into
apple:mainfrom
ravindudanthanarayana:fix/cares-tcp-socket-readiness

Conversation

@ravindudanthanarayana

@ravindudanthanarayana ravindudanthanarayana commented Nov 1, 2026 •

Copy link
Copy Markdown

Motivation

On macOS, every CAresDNSResolver query over TCP to a server more than a few milliseconds away fails with a spurious connection refused. That covers .USEVC and, because it goes through the same path, the TCP fallback for truncated UDP responses. The same server answers fine over UDP:

# Before, 1 attempt per query, macOS 27 / Swift 6.4
8.8.8.8 udp ok count=2
8.8.8.8 tcp connection refused:  (CAresError(code: 11)) after 0.013s
1.1.1.1 udp ok count=2
1.1.1.1 tcp connection refused:  (CAresError(code: 11)) after 0.013s

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 through sock_state_cb to ares_process_fd every 10ms, without checking that it's ready. sock_state_cb only reports what c-ares is interested in, while ares_process_fd means the socket is ready. For a TCP socket that is still connecting:

  1. c-ares registers interest in writing, and the next poll reports the socket as writable.
  2. process_write takes that to mean the connection is established, sets ARES_CONN_STATE_CONNECTED and calls ares_conn_flush.
  3. send on a socket in SYN_SENT fails with ENOTCONN on Darwin (checked directly: errno 57).
  4. ares_conn_flush maps any write error other than would-block to ARES_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-blocking poll (WSAPoll on Windows). POLLERR/POLLHUP count as ready for whatever c-ares was waiting on, so failed connections still reach it.
  • QueryProcessor.poll() reports only ready sockets, then calls ares_process_fd once with ARES_SOCKET_BAD for both, because timeouts must fire even when nothing is ready. An idle resolver still doesn't call into c-ares.
  • The snapshot, readiness check and reporting all happen under the channel lock, so a socket can't be closed and reused between being checked and being reported.

No public API changes.

Result

TCP queries wait for the handshake, then succeed, or end in .timeout if it never finishes:

# After
8.8.8.8 udp ok count=2
8.8.8.8 tcp ok count=2
1.1.1.1 udp ok count=2
1.1.1.1 tcp ok count=2

Callers that matched on .connectionRefused for 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), as CAresCancellationTests already does. These tests skip via XCTSkip on 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 Without the fix With the fix
test_tcpQuery_whileHandshakeIsInProgress_timesOut fails passes
test_tcpQuery_whileHandshakeIsInProgress_cancellationIsIsolated fails passes
test_readySockets_doesNotReportSocketThatIsStillConnecting n/a, tests the new helper passes
test_readySockets_reportsOnlyWhatIsReady n/a, tests the new helper passes
test_tcpQuery_isAnswered passes (guard) passes
test_concurrentQueries_areAllAnswered (50 each over UDP and TCP) not run passes
test_udpQuery_truncatedResponse_fallsBackToTCP passes (guard) passes
test_tcpQuery_connectionRefused_failsWithoutWaitingForTimeout passes (guard) passes

The "without" column comes from reverting DNSResolver_c-ares.swift while 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 existing CAresDNSResolverTests use public DNS.
  • CAresSocketReadinessTests, CAresCancellationTests and CAresSynchronousFailureTests repeated 10 times: 0 failures.
  • swift build --build-tests -Xswiftc -warnings-as-errors and swift build -c release -Xswiftc -warnings-as-errors: clean.
  • swift format lint --strict: clean.
  • The before/after output above is a manual run against public resolvers and isn't part of the test suite.

Not verified, please look at CI for these:

  • Linux and Windows have not been built or run, including the WSAPoll path. CI is the first time that code gets compiled.
  • I'd expect Linux not to show the failure, since send on a connecting non-blocking socket should return EAGAIN there, so the regression tests would pass with or without the fix. Windows may be affected, since it has WSAENOTCONN and c-ares is its default backend. I haven't confirmed either.
  • A slow handshake that eventually succeeds is only covered by the manual run above, because I couldn't produce one deterministically on loopback on macOS.
  • Lookups on dual-stacked networks fail #78 reports the same 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.

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
ravindudanthanarayana marked this pull request as ready for review November 1, 2026 11:16
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