I finally found a time to experiment with Network.framework
and I find the experience very pleasant. The API looks well thought out and is a pleasure to work with.
My app (on the App Store for around 13 years) uses UDP networking to create a mesh between multiple devices where each device acts as a server and client at the same time.
It does that by creating one UDP socket
on each device bound to a *:<port>
and then uses that socket to sendmsg
to other peers. That way all peers can communicate with each other using their well known <port>
. This all works great and fine.
To test the performance and verify the functionality I have multiple XCTestCase
scenarios where I create multiple peers and simulate various communications and verify their correctness. Within the XCTestCase
process that means creating multiple underlying socket
s and then bind
them to multiple local random <port>
s. Works great.
Now I'm trying to port this functionality to Network.framework
.
Let's assume two peers for now. Code simplified.
Prepare NWParameter
instances for both peers, plain UDP for now.
// NOTE: params for peer 1
let p1 = NWParameters(dtls: nil, udp: .init())
p1.requiredLocalEndpoint = .hostPort(host: "0.0.0.0", port: 2222)
p1.allowLocalEndpointReuse = true
// NOTE: params for peer 2
let p2 = NWParameters(dtls: nil, udp: .init())
p2.requiredLocalEndpoint = .hostPort(host: "0.0.0.0", port: 3333)
p2.allowLocalEndpointReuse = true
Create NWListener
s for each peer.
// NOTE: listener for peer 1 - callbacks omitted for brevity
let s1 = try NWListener(using: parameters)
s1.start(queue: DispatchQueue.main)
// NOTE: listener for peer 2 - callbacks omitted for brevity
let s2 = try NWListener(using: parameters)
s2.start(queue: DispatchQueue.main)
The listeners start correctly and I can verify that I have two UDP ports open on my machine and bound to port 2222
and 3333
. I can use netcat -u
to send UDP packets to them and correctly see the appropriate NWConnection
objects being created and all callbacks invoked. So far so good.
Now in that XCTestCase
, I want to exchange a packets between peer1 and peer2. So I will create appropriate NWConnection
and send data.
// NOTE: connection to port 3333 from port 2222
let c1 = NWConnection(host: "127.0.0.1", port: 3333, using: p1)
c2.start(queue: DispatchQueue.main)
// NOTE: wait for the c1 state .ready
c2.send(content: ..., completion: ...)
And now comes the problem.
The connection transitions to .preparing
state, with correct parameters, and then to .waiting
state with Error 48
.
[L1 ready, local endpoint: <NULL>, parameters: udp, local: 0.0.0.0:2222, definite, attribution: developer, server, port: 3333, path satisfied (Path is satisfied), interface: en0[802.11], ipv4, dns, uses wifi, service: <NULL>]
[L2 ready, local endpoint: <NULL>, parameters: udp, local: 0.0.0.0:3333, definite, attribution: developer, server, port: 2222, path satisfied (Path is satisfied), interface: en0[802.11], ipv4, dns, uses wifi, service: <NULL>]
nw_socket_connect [C1:1] connectx(6 (guarded), [srcif=0, srcaddr=0.0.0.0:2222, dstaddr=127.0.0.1:3333], SAE_ASSOCID_ANY, 0, NULL, 0, NULL, SAE_CONNID_ANY) failed: [48: Address already in use]
nw_socket_connect [C1:1] connectx failed (fd 6) [48: Address already in use]
nw_socket_connect connectx failed [48: Address already in use]
state: preparing connection: [C1 127.0.0.1:2222 udp, local: 0.0.0.0:2222, attribution: developer, path satisfied (Path is satisfied), interface: lo0]
state: waiting(POSIXErrorCode(rawValue: 48): Address already in use) connection: [C1 127.0.0.1:3333 udp, local: 0.0.0.0:2222, attribution: developer, path satisfied (Path is satisfied), interface: lo0]
I believe this happens because the connection c1
essentially tries to create under the hood a new socket
or something
instead of reusing the one prepared for s1
.
I can make the c1
work if I create the NWConnection
without binding to the same localEndpoint
as the listener s1
. But in that case the packets sent via c1
use random outgoing port.
What am I missing to make this scenario work in Network.framework
?
P.S. I was able to make it work using the following trick:
- bind the
s1
NWListener
local endpoint to::2222
(IPv6) - connect the
c1
NWConnection
to127.0.0.1:3333
(IPv4)
That way packets on the wire are sent/received correctly.
I would believe that this is a bug in the Network.framework
. I can open a DTS if more information is needed.
On each device you bind one UDP socket to a random port. And that socket/port is used for all outgoing and incoming communication with the other peers.
OK. In theory that should be compatible with Network framework’s UDP support.
But it breaks when
Port1
, andPort2
are used viaNWListener
orNWConnection
in the same (UNIX) process and on the same networking interface.
Right. I think this is a known issue. It’s related to the issue discussed here, but it’s not exactly the same.
Consider the program pasted in below. This starts two connections, with the flow tuples:
-
localIP / 12345 / 93.184.216.34 / 23456
-
localIP / 12345 / 93.184.216.34 / 23457
This should be feasible because UDP flows are uniquely identified by their tuples, and these tuples are distinct.
However, when you run it you get this [1]:
connection 23456 did change state, new: preparing
connection 23456 did change state, new: ready
connection 23457 did change state, new: preparing
connection 23457 did change state, new: waiting(POSIXErrorCode(rawValue: 48): Address already in use)
The second connection is failing with EADDRINUSE
.
This result confirms that your on-the-wire protocol won’t work with the current Network framework. You absolutely need to be able to start two connections to different peers with the same source port. Given that, I encourage you to file your own bug. Please post your bug number, just for the record
Note that doesn’t involve NWListener
at all; it’s just two outgoing connections. Adding NWListener
into the mix is not going to improve things |-:
to make it work on the Apple Watch
You’ve read TN3135 Low-level networking on watchOS, right?
Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"
[1] I’m testing on 14.3.1 but this isn’t a new problem.
import Foundation
import Network
let localPort: NWEndpoint.Port = 12345
var connections: [NWConnection] = []
func startFlow(remotePort: UInt16) {
let params = NWParameters.udp
params.allowLocalEndpointReuse = true
params.requiredLocalEndpoint = NWEndpoint.hostPort(host: "0.0.0.0", port: localPort)
let conn = NWConnection(host: "93.184.216.34", port: .init(rawValue: remotePort)!, using: params)
conn.stateUpdateHandler = { newState in
print("connection \(remotePort) did change state, new: \(newState)")
}
conn.start(queue: .main)
connections.append(conn)
}
func main() {
startFlow(remotePort: 23456)
startFlow(remotePort: 23457)
dispatchMain()
}
main()