NEAppProxyUDPFlow.writeDatagrams fails with "The datagram was too large" on macOS 15.x, macOS 26.x

I'm implementing a NEDNSProxyProvider on macOS 15.x and macOS 26.x. The flow works correctly up to the last step — returning the DNS response to the client via writeDatagrams.

Environment:

  • macOS 15.x, 26.x
  • Xcode 26.x
  • NEDNSProxyProvider with NEAppProxyUDPFlow

What I'm doing:

override func handleNewFlow(_ flow: NEAppProxyFlow) -> Bool {
    guard let udpFlow = flow as? NEAppProxyUDPFlow else { return false }
    
    udpFlow.readDatagrams { datagrams, endpoints, error in
        // 1. Read DNS request from client
        // 2. Forward to upstream DNS server via TCP
        // 3. Receive response from upstream
        // 4. Try to return response to client:
        
        udpFlow.writeDatagrams([responseData], sentBy: [endpoints.first!]) { error in
            // Always fails: "The datagram was too large"
            // responseData is 50-200 bytes — well within UDP limits
        }
    }
    return true
}

Investigation:

I added logging to check the type of endpoints.first :

// On macOS 15.0 and 26.3.1:
// type(of: endpoints.first) → NWAddressEndpoint
// Not NWHostEndpoint as expected

On both macOS 15.4 and 26.3.1, readDatagrams returns [NWEndpoint] where each endpoint appears to be NWAddressEndpoint — a type that is not publicly documented.

When I try to create NWHostEndpoint manually from hostname and port, and pass it to writeDatagrams, the error "The datagram was too large" still occurs in some cases.

Questions:

  1. What is the correct endpoint type to pass to writeDatagrams on macOS 15.x, 26.x?
  2. Should we pass the exact same NWEndpoint objects returned by readDatagrams, or create new ones?
  3. NWEndpoint, NWHostEndpoint, and writeDatagrams are all deprecated in macOS 15. Is there a replacement API for NEAppProxyUDPFlow that works with nw_endpoint_t from the Network framework?
  4. Is the error "The datagram was too large" actually about the endpoint type rather than the data size?

Any guidance would be appreciated. :-))

Answered by DTS Engineer in 881958022

I’m not sure what’s causing the main error here, but let’s start with endpoints, and specifically my NWEndpoint History and Advice post. This explains the general landscape.

NEAppProxyUDPFlow has read and write methods that use Network.NWEndpoint type. See here and here.

These are the Swift async versions; there are also equivalent completion handler versions.

In terms of how to handle endpoints, it’s best to approach this from the perspective of the client, that is, the DNS client that’s issuing DNS requests to resolve queries. And specifically a BSD Sockets client, which will:

  1. Open a socket.
  2. Optionally connect the socket to an endpoint (aka address).
  3. Send a datagram. If it connected the socket, it can call one of the send routines that doesn’t take an endpoint. If it didn’t connect the socket, it must supply an endpoint at this point.
  4. Receive a datagram, along with the source of that datagram.

From your perspective steps 1 and 2 result in a new flow. If you want to know what endpoint the client connected to, implement the handleNewUDPFlow(_:initialRemoteFlowEndpoint:) method from the NEAppProxyUDPFlowHandling protocol [1]. However, be aware that the client might not be connecting to an endpoint, in which case the system calls the vanilla handleNewFlow(_:) method.

You then connect via your underlying infrastructure and, when you’re done, call the open(withLocalFlowEndpoint:) method to:

  • Tell the flow that you’re ready, and
  • If appropriate, override the local endpoint.

This local endpoint is what the client gets when it calls getsockname, or the currentPath property if it’s using Network framework.

Your provider then sets up a datagram read. This completes with the series of datagrams and endpoints, which are the datagrams and endpoints supplied by the client in step 3. The endpoints may or may not match the initial remote endpoint you got previously. Remember that, in the BSD Sockets case, each datagram can have its own destination endpoint.

You should remember the endpoints for each request and write each reply with its corresponding endpoint. Again, think about this from the client’s perspective. When it sends a DNS request to address X, it expects to receive the reply from host X. If it gets a reply from some other host, it’ll likely throw it away.

I think that should be enough for you to sort out your endpoint concerns. Please apply these changes and let me know how you get along.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] In this and other cases I’m linking to the new methods, just to demonstrate that the Network framework NWEndpoint APIs do actually exist. These have poor documentation. For better docs, switch over to the legacy method.

I’m not sure what’s causing the main error here, but let’s start with endpoints, and specifically my NWEndpoint History and Advice post. This explains the general landscape.

NEAppProxyUDPFlow has read and write methods that use Network.NWEndpoint type. See here and here.

These are the Swift async versions; there are also equivalent completion handler versions.

In terms of how to handle endpoints, it’s best to approach this from the perspective of the client, that is, the DNS client that’s issuing DNS requests to resolve queries. And specifically a BSD Sockets client, which will:

  1. Open a socket.
  2. Optionally connect the socket to an endpoint (aka address).
  3. Send a datagram. If it connected the socket, it can call one of the send routines that doesn’t take an endpoint. If it didn’t connect the socket, it must supply an endpoint at this point.
  4. Receive a datagram, along with the source of that datagram.

From your perspective steps 1 and 2 result in a new flow. If you want to know what endpoint the client connected to, implement the handleNewUDPFlow(_:initialRemoteFlowEndpoint:) method from the NEAppProxyUDPFlowHandling protocol [1]. However, be aware that the client might not be connecting to an endpoint, in which case the system calls the vanilla handleNewFlow(_:) method.

You then connect via your underlying infrastructure and, when you’re done, call the open(withLocalFlowEndpoint:) method to:

  • Tell the flow that you’re ready, and
  • If appropriate, override the local endpoint.

This local endpoint is what the client gets when it calls getsockname, or the currentPath property if it’s using Network framework.

Your provider then sets up a datagram read. This completes with the series of datagrams and endpoints, which are the datagrams and endpoints supplied by the client in step 3. The endpoints may or may not match the initial remote endpoint you got previously. Remember that, in the BSD Sockets case, each datagram can have its own destination endpoint.

You should remember the endpoints for each request and write each reply with its corresponding endpoint. Again, think about this from the client’s perspective. When it sends a DNS request to address X, it expects to receive the reply from host X. If it gets a reply from some other host, it’ll likely throw it away.

I think that should be enough for you to sort out your endpoint concerns. Please apply these changes and let me know how you get along.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] In this and other cases I’m linking to the new methods, just to demonstrate that the Network framework NWEndpoint APIs do actually exist. These have poor documentation. For better docs, switch over to the legacy method.

Hi Quinn, Thank you so much for the detailed explanation and the link to your NWEndpoint History and Advice post — that was exactly what we needed.

Following your advice we:

  • Adopted the NEAppProxyUDPFlowHandling protocol and implemented handleNewUDPFlow (_:initialRemoteFlowEndpoint:)
  • Switched to the new Swift async readDatagrams() API which returns [(Data, Network.NWEndpoint)]
  • Stored the Network.NWEndpoint directly without any conversion
  • Used the new async writeDatagrams(_:) API passing the endpoint directly

This resolved our long-standing "The datagram was too large" error which was caused by NWHostEndpoint internally creating an NWAddressEndpoint on macOS 15.

We are currently testing the solution. Will report back with the final results. Thank you again for your time and expertise!

Hi Quinn,

Following up on your earlier advice about using NEAppProxyUDPFlowHandling and the async readDatagrams / writeDatagrams API.

I’ve updated my DNS proxy as follows:

  • I now implement NEAppProxyUDPFlowHandling and handle DNS over UDP via the async API.
  • For each flow, I store the Network.NWEndpoint that comes from readDatagrams() and then pass that same endpoint back into writeDatagrams.
  • I no longer use NWHostEndpoint / NetworkExtension.NWEndpoint anywhere in this path.

Relevant code (stripped down):

private func readFirstDatagrams(from udpFlow: NEAppProxyUDPFlow,
                                into flow: Flow,
                                channel: Channel) {
    Task {
        let (pairs, error) = await udpFlow.readDatagrams()
        if let error = error {
            // handle error...
            return
        }
        guard let pairs = pairs, !pairs.isEmpty else {
            // retry...
            return
        }
        let (firstData, firstEndpoint) = pairs[0]
        let size = firstData.count
        MainLogger.shared.log(message:
          "UDP recv \(pairs.count) datagrams, first size=\(size) from=\(firstEndpoint) type=\(type(of: firstEndpoint))"
        )
        // Force a hostPort endpoint
        let hostPortEndpoint: Network.NWEndpoint
        switch firstEndpoint {
        case let .hostPort(host, port):
            hostPortEndpoint = .hostPort(host: host, port: port)
        default:
            let desc = firstEndpoint.debugDescription // e.g. "192.0.2.1:53"
            let parts = desc.components(separatedBy: ":")
            let portStr = parts.last ?? "53"
            let hostStr = parts.dropLast().joined(separator: ":")
            let host = Network.NWEndpoint.Host(hostStr)
            let port = Network.NWEndpoint.Port(portStr) ?? .init(rawValue: 53)!
            hostPortEndpoint = .hostPort(host: host, port: port)
        }
        MainLogger.shared.log(message:
          "FORCE endpoint hostPort=\(hostPortEndpoint) type=\(type(of: hostPortEndpoint))"
        )   flow.updateNetworkEndpoint(hostPortEndpoint)
        // Validate and send to upstream (TCP via SwiftNIO)
        // ...
    }
}
// Flow.sendResponseToSystem
func sendResponseToSystem(_ data: Data) {
    guard let udpFlow = udpFlow else { return }
    guard let endpoint = networkEndpoint else { return }
    MainLogger.shared.log(message:
      "\(logPrefix) sendResponseToSystem: \(data.count) bytes → \(endpoint)"
    )
    let preview = data.prefix(4).map { String(format: "%02X", $0) }.joined(separator: " ")
    MainLogger.shared.log(message:
      "\(logPrefix) data preview: \(preview)"
    )
    MainLogger.shared.log(message:
      "\(logPrefix) endpoint type=\(type(of: endpoint)) desc=\(endpoint)"
    )
    Task {
        do {
            try await udpFlow.writeDatagrams([(data, endpoint)])
            MainLogger.shared.log(message: "\(logPrefix) writeDatagrams OK")
        } catch {
            MainLogger.shared.log(message:
              "\(logPrefix) writeDatagrams error: \(error.localizedDescription)"
            )
        }
    }
}

On macOS 26.x (DNS proxy system extension), with this code I still consistently hit The datagram was too large on a small, valid DNS response (67 bytes). Here is a complete log for a single dig example.com flow, with upstream details anonymised:

FLOW new from=com.apple.dig type=NEAppProxyUDPFlow remoteEndpoint=192.168.0.1:53
… upstream TCP connection to our DNS backend is established successfully …

UDP recv 1 datagrams, first size=39 from=192.168.0.1:53 type=NWEndpoint
✅ FORCE endpoint hostPort=192.168.0.1:53 type=NWEndpoint ✅
✅ Flow@… updateNetworkEndpoint=192.168.0.1:53 type=NWEndpoint ✅
DNS valid packet size=39, datagrams=1 → sendPackets
… TCP write of the DNS query over the upstream connection …

FlowChannelHandler: complete response 67 bytes → client ✅
✅ Flow@… sendResponseToSystem: 67 bytes → 192.168.0.1:53 ✅
⚠️ Flow@… data preview: 58 C8 81 80 ⚠️  // looks like a normal DNS answer
✅ Flow@… endpoint type=NWEndpoint desc=192.168.0.1:53
🆘 Flow@… writeDatagrams error: The datagram was too large 🆘

So, at this point:

  • I’m using the async readDatagrams / writeDatagrams API.
  • I store and reuse Network.NWEndpoint (a .hostPort endpoint), no NWHostEndpoint.
  • The response is small (67 bytes) and looks like a valid DNS answer.
  • Yet writeDatagrams([(data, endpoint)]) still fails with The datagram was too large.

Is there any known issue or additional requirement for NEAppProxyUDPFlow.writeDatagrams on macOS 26.x that could explain this error in this configuration? Or is this something you’d like me to file as a bug with a sysdiagnose?

Thanks in advance for any pointers.

Honestly, this is all a bit of a mystery |-:

I’d like to make sure I understand this bit correctly:

with this code I still consistently hit

So you see this every time? Or are there some cases where this works?

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Hi Quinn,

I have an additional clarification about the logs from the target Mac, now that I’ve instrumented the code more precisely. On this machine I actually see two different behaviours for NEAppProxyUDPFlow.writeDatagrams:

  • In the main DNS path (NEDNSProxyProvider → NEAppProxyUDPFlow → TCP upstream via SwiftNIO), writeDatagrams always fails with The datagram was too large, even for very small responses (for example 33, 50, 66, 67, 147, 193 bytes).
  • The only writeDatagrams OK entries I see in the logs come from a separate passthrough handler that talks directly to a fallback DNS server (8.8.8.8) and uses a different code path.

So, for the specific flow we are discussing (the proxy that forwards DNS over TCP to our upstream and then sends the response back to the client), writeDatagrams never succeeds on this Mac: every attempt ends with The datagram was too large, regardless of the actual payload size.

This seems to match what you described about the subtle differences between endpoint types and how the deprecated APIs interact with the newer implementation under macOS 26.x, but from the app’s perspective the failure mode is effectively “100% repro” for this TCP‑upstream DNS path on this machine, while the passthrough path using the same NEAppProxyUDPFlow type can still succeed.

If it would be useful, I can prepare a minimal sample based exactly on this working / non‑working split: one code path (passthrough) where writeDatagrams succeeds, and another (the TCP upstream DNS proxy) where the same API consistently fails on the same machine.

NEAppProxyUDPFlow.writeDatagrams fails with "The datagram was too large" on macOS 15.x, macOS 26.x
 
 
Q