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.

Accepted Answer

Interesting.

Honestly, I’m skeptical that the specific flavour of API you’re using will affect this. The APIs you call are just wrappers that convert the old flavour of endpoint to the new flavour, or vice versa. For example, the -writeDatagrams:sentByFlowEndpoints:completionHandler: method is marked as ‘refined for Swift’ [1] and then there’s a Swift version that looks like this:

func writeDatagrams(_ array: [(Data, Network.NWEndpoint)], completionHandler: @escaping (Error?) -> Void) {
    let datagrams = array.map { $0.0 }
    let endpoints = array.map { oldEndpointFromNewEndpoint($0.1) }
    __writeDatagrams(datagrams, sentByFlow: addresses, completionHandler: completionHandler)
}

where:

  • oldEndpointFromNewEndpoint(…) is a function that converts from the NW to the NE endpoint flavour, and
  • __writeDatagrams(…) is the ‘hidden’ version of the original Objective-C method.

But that leaves me at a loss to explain the behaviour you’re seeing. I had a look at the original of the The datagram was too large string, and it’s tied to the NEAppProxyFlowErrorDatagramTooLarge error. That error is only raised in very specific circumstances, namely, where the size of the data is larger than the max datagram size of the flow. That max size is set up when the flow ‘connects’.

Are you absolutely sure that the flow has finished opening, and finished successfully, at the time you start writing datagrams? If the flow hasn’t opened yet, or failed to open, the max datagram size would default to 0 and you’d see this error.

Specifically, you want to make sure that -openWithLocalFlowEndpoint:completionHandler: [2] has completed and with the error parameter being set to nil [3].

Share and Enjoy

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

[1] In the Swift 6 section of NetworkExtension.apinotes, it’s labeled as SwiftPrivate.

[2] Or the older -openWithLocalEndpoint:completionHandler:, or the various Swift equivalents.

[3] Alternatively, if you’re calling the Swift async function variants then you want to make sure that it’s returned without throwing an error.

Hi Quinn,

Thanks a lot for the detailed explanation about NEAppProxyFlowErrorDatagramTooLarge and the flow’s max datagram size. You were absolutely right that the problem wasn’t the endpoint flavour but the flow not being fully opened yet when I started writing datagrams.

I updated my handleNewFlow(_:) to explicitly await udpFlow.open(withLocalFlowEndpoint: nil) before connecting to the upstream and to gate writeDatagrams on a simple “flow opened” flag. After this change the error disappeared, and dig google.com now resolves successfully every time, including the 67‑byte responses that previously triggered “The datagram was too large”.

Your pointers about when the max datagram size is established and how openWithLocalFlowEndpoint fits into that saved me a lot of time. Really appreciate your help.

Best regards,

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