TCPTransports/NWTCPTransport.swift
/* |
Copyright (C) 2018 Apple Inc. All Rights Reserved. |
See LICENSE.txt for this sample’s licensing information |
Abstract: |
Implements `TCPTransport` on a `NWTCPConnection`. |
*/ |
import NetworkExtension |
/// An implementation of the `TCPTransport` protocol that uses an `NWTCPConnection` |
/// to run the TCP connection. This type supports both connect-by-name and TLS. |
/// |
/// - important: This class will only work in the context of a Network Extension |
/// provider because the only way to create an `NWTCPConnection` is via the |
/// `NEProvider.createTCPConnection(to:enableTLS:tlsParameters:delegate:)` |
/// method. That means you can’t use it ‘out of the box’ here. I’ve included |
/// it here to act as a starting point if you happen to be working in that |
/// context. |
/// |
/// For more information on how to to use this type, see the `TCPTransport` |
/// protocol. |
class NWTCPTransport : TCPTransport { |
/// Creates an object that will communicate over an `NWTCPConnection` |
/// created by calling the connection maker closure. |
/// |
/// The `connectionMaker` closure means that the client is in charge of |
/// connection creation, which is useful because of the very limited |
/// circumstances under which `NWTCPConnection` is available. See the |
/// documentation for the `NWTCPTransport` class for more information about |
/// this. |
/// |
/// |
/// - Parameters: |
/// - name: A name for the connection; not used internally, but handy while |
/// debuging. |
/// - queue: The queue on which to operate; delegate callbacks are called |
/// on this queue. |
/// - connectionMaker: A closure called to create the `NWTCPConnection` |
/// for this object. The resulting `NWTCPConnection` is ‘owned‘ by this |
/// object. You can’t go modifying it after the fact. |
required init(name: String, queue: DispatchQueue, connectionMaker: @escaping () -> NWTCPConnection) { |
dispatchPrecondition(condition: .onQueue(queue)) |
self.name = name |
self.queue = queue |
self.connectionMaker = connectionMaker |
} |
/// Creates an object that will communicate with the specified host and |
/// port, optionally using TLS. |
/// |
/// - important: In the context of this sample this method will always throw |
/// `nwTCPConnectionNotSupported`. See the documentation for the |
/// `NWTCPTransport` class for more information about this. |
/// |
/// - Parameters: |
/// - hostName: The host name to connect to (can be an IP address, both IPv4 and IPv6). |
/// - port: The port on which to connect. |
/// - useTLS: Whether to enable TLS or not. |
/// - queue: The queue on which to operate; delegate callbacks are called |
/// on this queue. |
/// - Throws: Always, as discussed above. |
convenience init(hostName: String, port: Int, useTLS: Bool, queue: DispatchQueue) throws { |
guard NWTCPTransport.isNWTCPConnectionAvailable else { |
throw NWTCPTransport.Error.nwTCPConnectionNotSupported |
} |
self.init(name: hostName, queue: queue, connectionMaker: { |
let endpoint = NWHostEndpoint(hostname: hostName, port: String(port)) |
return NWTCPTransport.makeNWTCPConnection(endpoint: endpoint, enableTLS: useTLS) |
}) |
} |
deinit { |
// If this assert fires you’ve released the last reference to this |
// transport without stopping it. |
switch self._state { |
case .initialised: break |
case .starting: fatalError() |
case .started: fatalError() |
case .stopped: break |
} |
} |
/// The value passed to the initialiser. |
let name: String |
/// The value passed to the initialiser. |
let queue: DispatchQueue |
/// The value passed to the initialiser. |
let connectionMaker: () -> NWTCPConnection |
/// See the comments associated with the `TCPTransport` protocol. |
weak var delegate: TCPTransportDelegate? = nil |
/// See the comments associated with the `TCPTransport` protocol. |
var transportState: TCPTransportState { |
return self._state.transportState |
} |
/// See the comments associated with the `TCPTransport` protocol. |
func start() { |
dispatchPrecondition(condition: .onQueue(queue)) |
self.process(event: .start) |
} |
/// See the comments associated with the `TCPTransport` protocol. |
func send(message: String) { |
dispatchPrecondition(condition: .onQueue(queue)) |
self.process(event: .send(message: message)) |
} |
/// See the comments associated with the `TCPTransport` protocol. |
func stop() { |
dispatchPrecondition(condition: .onQueue(queue)) |
self.process(event: .stop(error: nil, notify: false)) |
} |
/// Errors returned by this module. |
/// |
/// - nwTCPConnectionNotSupported: Indicates that `NWTCPConnection` is not supported in this context. |
/// - connectionError: An error from the `NWTCPConnection`; the actual error is saved as an associated value. |
/// - framingError: An error from the line unframer; the actual error is saved as an associated value. |
enum Error : Swift.Error { |
case nwTCPConnectionNotSupported |
case connectionError(Swift.Error) |
case framingError(LineUnframer.Error) |
} |
/// The runtime state of this object. |
/// |
/// - important: This has a leading underscore so that the state event |
/// functions don't actually access it via `state` or `self.state`. |
private var _state: State = .initialised |
} |
extension NWTCPTransport { |
/// Holds the runtime state of this object. Each state maps to a state in |
/// `TCPTransportState` but many states have an associated value that holds |
/// all the values valid during that states. |
private enum State { |
case initialised |
case starting(Starting) |
case started(Started) |
case stopped(Error?) |
/// Returns the transport state associated with our state. |
var transportState: TCPTransportState { |
switch self { |
case .initialised: return .initialised |
case .starting: return .starting |
case .started: return .started |
case .stopped(let error): return .stopped(error) |
} |
} |
} |
private struct Starting { |
/// The `NWTCPConnection` itself. |
var connection: NWTCPConnection |
/// A reference to our observation of the `state` property of |
/// `connection`. |
var stateObservation: NSKeyValueObservation |
} |
/// Holds the values meaningful in the `.started` state. |
private struct Started { |
/// The `NWTCPConnection` itself. |
var connection: NWTCPConnection |
/// A reference to our observation of the `state` property of |
/// `connection`. |
var stateObservation: NSKeyValueObservation |
/// Frames lines to send as data. |
var framer: LineFramer |
/// Unframes received data to parse out the lines. |
var unframer: LineUnframer |
/// Creates a value from the starting state. |
init(starting: Starting) { |
self.connection = starting.connection |
self.stateObservation = starting.stateObservation |
self.framer = LineFramer() |
self.unframer = LineUnframer(maxLineLength: 1024) |
} |
} |
/// Events that trigger changes of state. |
/// |
/// - start: The client has called `start()`. |
/// - send: The client has called `send(message:)`. |
/// - stop: The client has called `stop()` (`notify` false) or something has |
/// caused the stream to stop (`notify` true). `error` will be nil if |
/// the stop was orderly (either because the client called `stop()` or |
/// because there was on EOF on the stream, or non-nil otherwise. |
/// - openCompleted: An open operation has completed. |
/// - readCompleted: A read operation has completed. |
private enum Event { |
case start |
case send(message: String) |
case stop(error: Error?, notify: Bool) |
case openCompleted |
case readCompleted(data: Data, error: Error?) |
} |
/// Processes an event in the state machine. All the heavy lifting here is |
/// done in state event functions, which take the state as a parameter and |
/// return a new state as the function result. |
private func process(event: Event) { |
dispatchPrecondition(condition: .onQueue(queue)) |
switch (self._state, event) { |
case (.initialised, .start): |
self._state = self.handleStart() |
case (_, .start): |
fatalError() |
case (.started(let started), .send(let message)): |
self._state = self.handleSend(message: message, started: started) |
case (_, .send): |
fatalError() |
case (.starting(let starting), .openCompleted): |
self._state = self.handleOpenCompleted(starting: starting) |
case (_, .openCompleted): |
fatalError() |
case (.started(let started), .readCompleted(let data, let error)): |
self._state = self.handleReadCompleted(data: data, error: error, started: started) |
case (_, .readCompleted): |
fatalError() |
case (let _state, .stop(let error, let notify)): |
self._state = self.handleStop(error: error, notify: notify, state: _state) |
} |
} |
/// Runs the supplied closure after the current state machine event has |
/// completed. This is primarily used to post delegate events from a |
/// a state event function. |
/// |
/// - warning: It is critical that the closure check the current state |
/// before acting. It’s quite possible for some intervening event to |
/// change the state to invalidate the resaon why the closure was |
/// scheduled in the first place. |
/// |
/// This function is safe to call from any thread, and expected that it will |
/// be called on secondary threads courtesy of `NWTCPConnection` callbacks. |
/// |
/// - Parameter body: The closure to call. This is passed the current state |
/// as a parameter but, unlike a state event function, is not able to |
/// return a new state directly (although it can call `process(event:)` |
/// if necessary). |
private func deferred(_ body: @escaping (State) -> Void) { |
self.queue.async { |
dispatchPrecondition(condition: .onQueue(self.queue)) |
body(self._state) |
} |
} |
/// The state event function for the `.start` event. Transitions the object |
/// from the `.initialised` state to the `.starting` state. |
/// |
/// - Returns: The new state. |
private func handleStart() -> State { |
dispatchPrecondition(condition: .onQueue(queue)) |
let connection = self.connectionMaker() |
let stateObservation = connection.observe(\NWTCPConnection.state, changeHandler: self.connectionStateDidChange) |
return .starting(Starting(connection: connection, stateObservation: stateObservation)) |
} |
/// The state event function for the `.send` event. Called when the object |
/// is in the `.started` state and typically leaves the object in the same |
/// state (although with various associated values changed). |
/// |
/// - Parameters: |
/// - message: The message to be sent. |
/// - started: The value associated with the `.started` state. |
/// - Returns: The new state. |
private func handleSend(message: String, started: Started) -> State { |
dispatchPrecondition(condition: .onQueue(queue)) |
let started = started |
let messageData = started.framer.data(for: [message]) |
started.connection.write(messageData) { (error) in |
if let error = error { |
self.deferred { _ in |
self.process(event: .stop(error: .connectionError(error), notify: true)) |
} |
} |
} |
return .started(started) |
} |
/// The state event function for the `.stop` event. Can be called in any |
/// state and transitions the object to the `.stopped` state. |
/// |
/// - Parameters: |
/// - error: If not nil, this holds the error that caused the |
/// transition; if nil, the transition was not due to an error (it was |
/// either triggered by the client calling `stop()` or by EOF on the |
/// network). |
/// - notify: If true, the client is notified of the stop via the |
/// `didStop(transport:)` delegate callback. |
/// - state: The current state. |
/// - Returns: The new state. |
private func handleStop(error: Error?, notify: Bool, state: State) -> State { |
dispatchPrecondition(condition: .onQueue(queue)) |
// If we’re already stopped, do nothing. |
if case .stopped = state { return state } |
// Clean up based on the current state. |
switch state { |
case .initialised: |
break |
case .starting(let starting): |
starting.connection.cancel() |
starting.stateObservation.invalidate() |
case .started(let started): |
started.connection.cancel() |
started.stateObservation.invalidate() |
case .stopped: |
fatalError() |
} |
// Set up a delegate callback, if required. |
if notify { |
self.deferred { _ in |
// We don't need to check the state here because the check above |
// means that only one person can ever schedule this delegate |
// callback. Moreover, if it was scheduled it needs to be |
// called. |
self.delegate?.didStop(transport: self) |
} |
} |
return .stopped(error) |
} |
/// The state event function for the `.openCompleted` event. Transitions the object |
/// from the `.staring` state to the `.started` state. |
/// |
/// Note that we can’t call the delegate directly here because we _return_ |
/// the new state, so the new state isn’t applied until we return. If we |
/// called the delegate directly, it would see us in the wrong state. |
/// |
/// Instead we defer the delegate callback via a deferred closure. That |
/// must check the state to make sure we haven’t failed in the interim. |
/// |
/// - Parameter starting: The value associated with the `.starting` state. |
/// - Returns: The new state. |
private func handleOpenCompleted(starting: Starting) -> State { |
dispatchPrecondition(condition: .onQueue(queue)) |
self.deferred { state in |
guard case .started = state else { return } |
self.delegate?.didStart(transport: self) |
} |
return self.setupRead(started: Started(starting: starting)) |
} |
/// The state event function for the `.readCompleted` event. Called when |
/// the object is in the `.started` state and typically leaves the object in |
/// the same state (although with various associated values changed). |
/// |
/// This code is a little tricky because: |
/// |
/// * The unframer can error, which will stop the connection. |
/// |
/// * The unframer can generate multiple messages, which each have to |
/// delivered to the delegate via a deferred closure. |
/// |
/// - Parameters: |
/// - data: The data that was read. |
/// - started: The value associated with the `.started` state. |
/// - Returns: The new state. |
private func handleReadCompleted(data: Data, error: Error?, started: Started) -> State { |
dispatchPrecondition(condition: .onQueue(queue)) |
var started = started |
// Run the data through the unframer. |
let messages: [String] |
do { |
messages = try started.unframer.lines(for: data) |
} catch let error as LineUnframer.Error { |
return self.handleStop(error: Error.framingError(error), notify: true, state: .started(started)) |
} catch { |
fatalError() |
} |
// Schedule the delivery of the messages to the delegate. When each of |
// deferred closures run they have to check the state of the object to |
// ensure it hasn't stopped in the meantime. |
for message in messages { |
self.deferred { state in |
guard case .started = state else { return } |
self.delegate?.didReceive(message: message, transport: self) |
} |
} |
// Set up the next read or, if we got an error (which might indicate an |
// EOF), schedule a `.stop` event to shut things down. Both of these |
// have to return an appropriate new state. |
if let error = error { |
self.deferred { state in |
guard case .started = state else { return } |
self.process(event: .stop(error: error.isEOF ? nil : error, notify: true)) |
} |
return .started(started) |
} else { |
return self.setupRead(started: started) |
} |
} |
/// Starts a read request on the stream task. Called when the object is in |
/// the `.started` state and leaves the object in the same state. |
/// |
/// - Parameter started: The value associated with the `.started` state. |
/// - Returns: The new state. |
private func setupRead(started: Started) -> State { |
dispatchPrecondition(condition: .onQueue(queue)) |
started.connection.readMinimumLength(1, maximumLength: 2048) { (data, error) in |
// The documentation for NWTCPConnection is very vague about the |
// semantics of the callback parameters but here’s how it breaks |
// down: |
// |
// * It’s possible for both `data` and `error` to be non-nil, indicating |
// we read some data and then got an error. |
// |
// * EOF is signalled by `ENOTCONN` (which is less than ideal both that's another |
// story). |
// |
// This design means that we have to pass the error along with the |
// `.readCompleted` event and then `handleReadCompleted(…)` has to |
// look at the error after processing the data. |
assert( (error != nil) || (data != nil) ) |
self.deferred { state in |
guard case .started = state else { return } |
self.process(event: .readCompleted(data: data ?? Data(), error: error.flatMap { Error.connectionError($0) } )) |
} |
} |
return .started(started) |
} |
/// This is a KVO observation callback for the `state` property on the |
/// `NWTCPConnection`. It’s only goal is to trigger the transition from our |
/// `.starting` state to the `.started` state by watching for the |
/// connection’s state going to `.connected`. The disconnect transition is |
/// not handled this way because we always have a permanent outstanding read |
/// request, and that read request will fail with an error if we disconnect. |
private func connectionStateDidChange(_: NWTCPConnection, _: NSKeyValueObservedChange<NWTCPConnectionState>) { |
self.deferred { (state) in |
guard case .starting(let starting) = state else { return } |
if starting.connection.state == .connected { |
self.process(event: .openCompleted) |
} |
} |
} |
} |
extension NWTCPTransport.Error { |
/// Returns true if the error should be treated as an EOF. As things |
/// currently stand, `NWTCPConnection` signals EOF via the `ENOTCONN` error |
/// code. |
fileprivate var isEOF: Bool { |
if case .connectionError(let errorObj as NSError) = self, errorObj.domain == NSPOSIXErrorDomain, errorObj.code == ENOTCONN { |
return true |
} else { |
return false |
} |
} |
} |
extension NWTCPTransport { |
/// This value is always false, indicating that `NWTCPConnection` is not available in this |
/// context. See the documentation for the `NWTCPTransport` class for more |
/// information about this. |
static let isNWTCPConnectionAvailable = false |
/// This method is a placeholder that you can replace with real code if you |
/// want to use `NWTCPTransport` in an environment where `NWTCPConnection` |
/// is supported. See the documentation for the `NWTCPTransport` class for |
/// more information about this. |
/// |
/// - Parameters: |
/// - endpoint: The endpoint to connect to. |
/// - enableTLS: Whether to enable TLS or not. |
/// - Returns: An connection object. |
static func makeNWTCPConnection(endpoint: NWHostEndpoint, enableTLS: Bool) -> NWTCPConnection { |
fatalError() |
} |
} |
Copyright © 2018 Apple Inc. All Rights Reserved. Terms of Use | Privacy Policy | Updated: 2018-05-10