An NIO NetworkClient class for SwiftUI needs help!

I know it's mostly to do with threading, but I'm a novice and completely lost.

I've been fooling around for more weeks than I care to remember, trying to get a simple TCP Client class working in a SwiftUI macOS application. My NIO json server is working fine and so is my macOS command line client application. Both are reincarnated from the official GitHub examples, NIOEchoServer and NIOEchoClient, but I've failed to get the Client code ported into a SwiftUI app.

The Swift-NIO team do say that NIO can be used as a client, but I can't find a single example for the UI case. So please, I'm begging for one of you capable people to show me the way.

Here's the interface I'm aiming for...

// A Simple TCP Network Client Class for SwiftUI

typealias ResponseCallbackType = (_ response: MyResponseStruct) -> Void

final class MyNetworkClient {

    // not shown here...
    // there also needs to be an Error type with message
    // that can be returned with each public function

    public func connect(host: String, port: Int, @escaping responseCallback: ResponseCallbackType) {
        // switch to the NIO thread
        // create and connect to a simple tcp server
        // KISS with no http, https, tls, ssh, etc
        // switch back to the MAIN thread
    }

    public func disconnect() {
        // switch to the NIO thread
        // close everything, so its ready for next time
        // switch back to the MAIN thread
    }

    public func request(request: MyRequestStruct) {
        // switch to the NIO thread
        // encode to json and send
        // switch back to the MAIN thread
    }

    // MARK: Private Functions and a Response Handler

    private final class MyResponseHandler: ChannelInboundHandler {
        // response = decode from incoming data
        // switch to the MAIN thread
        // callback responseCallback(response: response)
        // return to the NIO thread
    }

    //  private functions follow...
}

Note: I'm currently using Xcode 12.5.1 on macOS 11.4.

This is a full FOSS example of a NIO based SwiftUI client app: NeoIRC. Though whether it is SwiftUI or not doesn't really matter for NIO :slight_smile: Client is client and the NIOEchoClient example should work just fine.

Looks like your questions are mostly about threading? I think you can write to NIO channels from any thread. To transfer information from a NIO event loop (thread) back to the main thread, just do a:

DispatchQueue.main.async { your code }

or

RunLoop.main.perform { your code }

(e.g. this is an example in NeoIRC).

Note that on macOS/iOS you should use NIO Transport Services, i.e. the Network.framework as a basis. Depending on your needs it might be easier to just go with that in the first place, though NIO is better for more complex protocols. I have a small intro over here: Intro to Network.framework.

P.S. If you want to transfer JSON on a lightweight socket protocol, WebSocket might be something for you. A sample client is included in NIO.

2 Likes

I'll try to be a bit more specific with only 3 questions, marked as 1), 2) and 3) in the following snippet.

class NetworkService {
    var connected = false
    
1) What should these Type declarations really be?
    var group: EventLoopGroup
    var bootstrap: NIO.ClientBootstrap
    var channel: NIO.Channel?
    
    init() {
        self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
        self.bootstrap = ClientBootstrap(group: group)
            .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
            .channelInitializer { channel in
                channel.pipeline.addHandler(ResponseHandler())
            }
        defer {
            try! self.group.syncShutdownGracefully()
        }
    }
    
    public func connect(host: String, port: Int, completion: (_ success:Bool, _ error:String) -> Void) {
        
        if connected {
            completion(false, "ERROR: NetworkService already connected")
            return
        }
        do {
2) How should I change to the correct thread here?
            try channel = bootstrap.connect(host: host, port: port).wait()
        } catch {
            completion(false, "ERROR: NetworkService failed to connect")
            connected = false
            return
        }
        connected = true
        completion(true, "")
    }
    
    public func disconnect(completion: (_ success:Bool, _ error:String) -> Void) {
        if !connected {
            completion(false, "ERROR: already disconnected")
            return
        }
        do {
3)  How should I change to the correct thread here?
            try channel?.close().wait()
        } catch {
            completion(false, "ERROR: NetworkService failed to disconnect")
            return
        }
        connected = false
        completion(true, "")
    }

Of course, any other suggestions will be most appreciated. Thank you.

To answer your questions:

  1. Both your EventLoopGroup and your Bootstrap should be concrete types. In your example you're using MultiThreadedEventLoopGroup, so you'd use MultiThreadedEventLoopGroup and ClientBootstrap. If you were using NIOTSEventLoopGroup, then you'd use NIOTSConnectionBootstrap instead.
  2. Which thread is the "correct' one? bootstrap.connect is thread-safe. If you make bootstrap a let (and so prevent any writes to it) then you can call that on any thread. However, EventLoopFuture.wait has an extra rule: you must not call it from a NIO EventLoop thread.
  3. In this instance, channel.close is also thread-safe and can be called from any thread. However, here you are mutating the local var channel, which means your own NetworkService class needs to decide what thread it wants to mutate things on. Again, wait means that must not be a NIO event loop thread.

A note here: you're using EventLoopFuture.wait a lot. This is a risk if these functions are called on the main queue (that is, if they're called in response to user action and you haven't guarded this class with a private serial queue) as these functions can wait indefinitely, and will block the main queue while they do. I recommend instead of wait-ing on these Futures you instead attach callbacks to them that call DispatchQueue.main.async and then jump back to the main queue to modify your NetworkService.

1 Like

Hi Cory,

First let me say I really appreciate your help here. I kind of get what you're saying, but it's too technical for a simple novice. All I'm trying to do is get my real world application talking. My Raspberry Pi NIO server is working just fine and I've spent several weeks writing a macOS user interface application using a mock server to emulate the many devices connected.

I'm a seriously old amateur radio ham who's become interested in Digital Amateur Television, or DATV. The goal is to remotely monitor and control several power supplies, heat extractors, temperature and humidity sensors, and of course a microwave transmitter and receiver, plus the many video encoder and decoder parameters - all in real time from the comfort of my office - because all the hardware is situated very close to the antennas on the roof of my apartment.

Most amateurs are sensible enough to use old versions of Windows, but I like to be different. For macOS I really need an off-the-shelf solution for the networking stuff. I've enough on my plate with all the hardware. Considering Unix was originally built for networking all those years ago, I do wonder how it's become so complicated today. So, please could you offer your suggestions as copy & paste code, because I just need something that works.

They do say "A picture is worth a thousand words". If only Apple thought the same with their code!

Regards.

When it comes to GUI programming, a single rule is paramount: you may only interact with the UI from the main thread (in Apple's platforms also variously called main queue, main run loop, or main actor depending on context). This thread is responsible for all UI events, and also responsible for owning window buffers and other critical GUI elements. This thread is therefore something like a networking event loop: it must not be blocked by user code, because if it is then the UI will become entirely unresponsive, and you'll get the spinning beach ball.

Similarly, NIO event loops also own threads (or queues in some cases). These are by intention and construction different from the main thread. As a result, you get the problem you have here: a question of thread safety.

Unfortunately, it's not so straightforward as "show me the code to make things thread safe". There is no one way to make things thread safe. Instead, it's a question of application architecture. Different components have different "threading rules" and "threading guarantees". These rules might be: "may only be called on the main queue", "may only be called on the event loop thread", or "must not block". There are also guarantees some APIs make: things like "may be called from any thread", "guarantees you will be called back on the appropriate thread", etc. A program is made thread safe by thinking about all places where multithreaded operations may take place and asking ourselves three questions:

  1. On what thread is this code going to be executing?
  2. On what other threads may this code execute?
  3. On what thread do I need to be to perform the operation I want to perform?

In the case of SwiftUI, all SwiftUI view code must execute on the main thread, and all events triggered by SwiftUI will be executed on the main thread. Additionally, all mutation of @ObservableObjects must occur on the main thread as well. Practically speaking, all objects that are directly associated with UI state must be mutated on the main thread.

SwiftNIO's I/O code does not run on the main thread: it runs on its own event loop threads. As you have rightly asked, then, the question is: what operations are safe to perform on NIO objects from any thread, and what operations are safe to perform only from a specific thread?

NIO notes in the Channel documentation that:

All operations on Channel are thread-safe.

This concretely means you can call any method on a Channel from any thread, and nothing bad will happen. An important caveat is warranted here: while Channel is thread-safe, ChannelPipeline is not, and you can get to the ChannelPipeline from the Channel. So just remember to take some care there.

I did mention, however, that .wait() was a source of danger. Because Channel methods are thread-safe, they cannot generally return a result directly: after all, you may not be on the thread that needs to execute the operation. This is why NIO returns an EventLoopFuture. Similarly, it's common for Apple APIs to take a completion handler when doing the same thing.

In your case, it seems like the NetworkService class is likely going to be used to interact with the UI. In that case, it should live on the main queue. The result would be you'd want it to look like this:

class NetworkService {
    // These two fields may only be read or written on DispatchQueue.main.
    private var connected = false
    private var channel: NIO.Channel?
    
    // These two fields are `let` and so may be accessed on any queue.
    private let group: NIO.MultiThreadedEventLoopGroup
    private let bootstrap: NIO.ClientBootstrap
    
    init() {
        self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
        self.bootstrap = ClientBootstrap(group: group)
            .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
            .channelInitializer { channel in
                channel.pipeline.addHandler(ResponseHandler())
            }
    }
    
    public func connect(host: String, port: Int, completion: (_ success:Bool, _ error:String) -> Void) {
        // This will catch errors in our assumptions. Note that as we read `connected`
        // we are required to be on the main queue.
        dispatchPrecondition(condition: .onQueue(DispatchQueue.main))
        if connected {
            completion(false, "ERROR: NetworkService already connected")
            return
        }

        // whenCompleteBlocking is a NIO helper that will execute the callback provided
        // on the given queue. As we want to edit `connected` and `channel` we have to
        // execute this on `main`. We also want good hygiene around `completion`, which
        // we should aim to always call back on a single, well-defined queue. In this case,
        // it'll be `main`.
        bootstrap.connect(host: host, port: port).whenCompleteBlocking(onto: DispatchQueue.main) { result in
            do {
                self.channel = try result.get()
                self.connected = true
                completion(true, "")
            } catch {
                self.connected = false
                completion(false, "ERROR: NetworkService failed to connect")
                return
            }
        }
    }
    
    public func disconnect(completion: (_ success:Bool, _ error:String) -> Void) {
        // This will catch errors in our assumptions. Note that as we read `connected`
        // and `channel` we are required to be on the main queue.
        dispatchPrecondition(condition: .onQueue(DispatchQueue.main))
        if !connected {
            completion(false, "ERROR: already disconnected")
            return
        }

        channel?.close().whenCompleteBlocking(onto: DispatchQueue.main) { result in
            do {
                self.channel = try result.get()
                self.connected = false
                completion(true, "")
            } catch {
                completion(false, "ERROR: NetworkService failed to disconnect")
                return
            }
        }
    }
3 Likes

Wow and thank you. I didn't expect such a great reply. I'm assuming NetworkService is being used from the main thread, because I'm instantiating it inside the ViewModel.

var body: some Scene {
    WindowGroup {
        ContentView(vm: ViewModel())

Unfortunately, it refuses to build.

public func disconnect(completion: (_ success:Bool, _ error:String) -> Void) {
    // This will catch errors in our assumptions. Note that as we read `connected`
    // and `channel` we are required to be on the main queue.
    dispatchPrecondition(condition: .onQueue(DispatchQueue.main))
    if !connected {
        completion(false, "ERROR: already disconnected")
        return
    }

    channel?.close().whenCompleteBlocking(onto: DispatchQueue.main) { result in
        do {
            self.channel = try result.get() //BUILD ERROR: Cannot assign value of type 'Channel?.
            self.connected = false
            completion(true, "")
        } catch { //BUILD WARNING: 'catch' block is unreachable because o errors are thrown in 'do' block
            completion(false, "ERROR: NetworkService failed to disconnect")
            return
        }
    }
}

If I comment out the BUILD ERROR line, I get 2 new build errors

  • the channel?.close statement
  • the bootstrap.connect statement

I hope you're still around.

Ah, yes, that's a mistake on my end. Try this:

    channel?.close().whenCompleteBlocking(onto: DispatchQueue.main) { result in
        do {
            try result.get()
            self.channel = nil
            self.connected = false
            completion(true, "")
        } catch {
            completion(false, "ERROR: NetworkService failed to disconnect")
            return
        }
    }

Feel free to provide the other two errors.

1 Like

That certainly made a difference, because even my hacked ResponseHandler is not showing any errors. Od course, that's no reason to assume it will work. The other errors (there's always a catch), the are for the lines:

bootstrap.connect(host: host, port: port).whenCompleteBlocking(onto: DispatchQueue.main) { result in
// and
channel?.close().whenCompleteBlocking(onto: DispatchQueue.main) { result in

with both refusing to build because of:
"Escaping closure captures non-escaping parameter 'completion'"

Of course, I've no idea what kind of result they're expecting.

P.S. Matthew Eaton has followed up on my earlier Apple Developer Forum post with some suggestions. It was he who suggested I post here on the Swift Forum, I've posted a link to this thread into the Apple post. I found this sentence very difficult to write.

Thank you @ea7kir for moving the conversation over here. You are in good hands.

1 Like

For both your connect and disconnect functions, rewrite completion to be completion: @escaping (_ success:Bool, _ error:String) -> Void.

Fantastic !!

It can now open and close a connection multiple times without error. Also, my hacked NIOEcho ResponseHandler is receiving and decoding the response. There are a couple of things I need to investigate, so I won't pester you until I get stuck. One is that the first request fails, then all successive requests succeed, but this could be at the server end. The other will be to get the received response back into user space - instead of just print().

Just before you came to the rescue, I was wondering if I could get Swift to call a Python script, so thank you very much for helping me out.

software is much better when it works

Terms of Service

Privacy Policy

Cookie Policy