Designing a Swift library with data-race safety

https://rhonabwy.com/2024/04/29/designing-a-swift-library-with-data-race-safety/

I cut an initial release (0.1.0-alpha) of the library automerge-repo-swift. A supplemental library to Automerge swift, it adds background networking for sync and storage capabilities. The library extends code I initially created in the Automerge demo app (MeetingNotes), and was common enough to warrant its own library. While I was extracting those pieces, I leaned into the same general pattern that was used in the Javascript library automerge-repo. That library provides largely the same functionality for Automerge in javascript. I borrowed the public API structure, as well as compatibility and implementation details for the Automerge sync protocol. One of my goals while assembling this new library was to build it fully compliant with Swift’s data-race safety. Meaning that it compiles without warnings when I use the Swift compiler’s strict-concurrency mode.

There were some notable challenges in coming up to speed with the concepts of isolation and sendability. In addition to learning the concepts, how to apply them is an open question. Not many Swift developers have embraced strict concurrency and talked about the trade-offs or implications for choices. Because of that, I feel that there’s relatively little available knowledge to understand the trade-offs to make when you protect mutable state. This post shares some of the stumbling blocks I hit, choices I made, and lessons I’ve learned. My hope is that it helps other developers facing a similar challenge.

Framing the problem

The way I try to learn and apply new knowledge to solve these kinds of “new fangled” problems is first working out how to think about the problem. I’ve not come up with a good way to ask other people how to do that. I think when I frame the problem with good first-principles in mind, trade-offs in solutions become easier to understand. Sometimes the answers are even self-obvious.

The foremost principle in strict-concurrency is “protect your mutable state”. The compiler warnings give you feedback about potential hazards and data-races. In Swift, protecting the state uses a concept of an “isolation domain”. My layman’s take on isolation is “How can the compiler verify that only one thread is accessing this bit of data at a time”. There are some places where the compiler infers the state of isolation, and some of them still changing as we progress towards Swift 6. When you’re writing code, the compiler knows what is isolated (and non-isolated) – either by itself or based on what you annotated. When the compiler infers an isolation domain, that detail is not (yet?) easily exposed to developers. It really only shows up when there’s a mismatch in your assumptions vs. what the compiler thinks and it issues a strict-concurrency warning.

Sendability is the second key concept. In my layman’s terms again, something that is sendable is safe to cross over thread boundaries. With Swift 5.10, the compiler has enough knowledge of types to be able to make guarantees about what is safe, and what isn’t.

The first thing I did was lean heavily into making anything and everything Sendable. In hindsight, that was a bit of a mistake. Not disastrous, but I made a lot more work for myself. Not everything needs to be sendable. Taking advantage of isolation, it is fine – sometimes notably more efficient and easier to reason about – to have and use non-sendable types within an isolation domain. More on that in a bit.

My key to framing up the problem was to think in terms of making explicit choices about what data should be in an isolation region along with how I want to pass information from one isolation domain to another. Any types I pass (generally) need to be Sendable, and anything that stays within an isolation domain doesn’t. For this library, I have a lot of mutable state: networking connections, updates from users, and a state machines coordinating it all. All of it needed so a repository can store and synchronize Automerge documents. Automerge documents themselves are Sendable (I had that in place well before starting this work). I made the Automerge documents sendable by wrapping access and updates to anything mutable within a serial dispatch queue. (This was also needed because the core Automerge library – a Rust library accessed through FFI – was not safe for multi-threaded use).

Choosing Isolation

I knew I wanted to make at least one explicit isolation domain, so the first question was “Actor or isolated class?” Honestly, I’m still not sure I understand all the tradeoffs. Without knowing what the effect would be to start off with, I decided to pick “let’s use actors everywhere” and see how it goes. Some of the method calls in the design of the Automerge repository were easily and obviously async, so that seemed like a good first cut. I made the top-level repo an actor, and then I kept making any internal type that had mutable state also be it’s own actor. That included a storage subsystem and a network subsystem, both of which I built to let someone else provide the network or storage provider external to this project. To support external plugins that work with this library, I created protocols for the storage and network provider, as well as one that the network providers use to talk back to the repository.

The downside of that choice was two-fold – first setting things up, then interacting with it from within a SwiftUI app. Because I made every-darn-thing an actor, I hade to await a response, which meant a lot of potential suspension points in my code. That also propagated to imply even setup needed to be done within an async context. Sometimes that’s easy to arrange, but other times it ends up being a complete pain in the butt. More specifically, quite a few of the current Apple-provided frameworks don’t have or provide a clear path to integrate async setup hooks. The server-side Swift world has a lovely “set up and run” mechanism (swift-service-lifecycle) it is adopting, but Apple hasn’t provided a similar concept the frameworks it provides. The one that bites me most frequently is the SwiftUI app and document-based app lifecycle, which are all synchronous.

Initialization Challenges

Making the individual actors – Repo and the two network providers I created – initializable with synchronous calls wasn’t too bad. The stumbling block I hit (that I still don’t have a great solution to) was when I wanted to add and activate the network providers to a repository. To arrange that, I’m currently using a detached Task that I kick off in the SwiftUI App’s initializer:

public let repo = Repo(sharePolicy: .agreeable)
public let websocket = WebSocketProvider()
public let peerToPeer = PeerToPeerProvider(
    PeerToPeerProviderConfiguration(
        passcode: "AutomergeMeetingNotes",
        reconnectOnError: true,
        autoconnect: false
    )
)
@main
struct MeetingNotesApp: App {
    var body: some Scene {
        DocumentGroup {
            MeetingNotesDocument()
        } editor: { file in
            MeetingNotesDocumentView(document: file.document)
        }
        .commands {
            CommandGroup(replacing: CommandGroupPlacement.toolbar) {
            }
        }
    }
    init() {
        Task {
            await repo.addNetworkAdapter(adapter: websocket)
            await repo.addNetworkAdapter(adapter: peerToPeer)
        }
    }
}

Swift Async Algorithms

One of the lessons I’ve learned is that if you find yourself stashing a number of actors into an array, and you’re used to interacting with them using functional methods (filter, compactMap, etc), you need to deal with the asynchronous access. The standard library built-in functional methods are all synchronous. Because of that, you can only access non-isolated properties on the actors. For me, that meant working with non-mutable state that I set up during actor initialization.

The second path (and I went there) was to take on a dependency to swift-async-algorithms, and use its async variations of the functional methods. They let you “await” results for anything that needs to cross isolation boundaries. And because it took me an embarrasingly long time to figure it out: If you have an array of actors, the way to get to an AsyncSequence of them is to use the async property on the array after you’ve imported swift-async-algorithms. For example, something like the following snippet:

let arrayOfActors: [YourActorType] = []
let filteredResults = arrayOfActors.async.filter(...)

Rethinking the isolation choice

That is my first version of this library. I got it functional, then turned around and tore it apart again. In making everything an actor, I was making LOTS of little isolation regions that the code had to hop between. With all the suspension points, that meant a lot of possible re-ordering of what was running. I had to be extrodinarily careful not to assume a copy of some state I’d nabbed earlier was still the same after the await. (I still have to be, but it was a more prominent issue with lots of actors.) All of this boils down to being aware of actor re-entrancy, and when it might invalidate something.

I knew that I wanted at least one isolation region (the repository). I also want to keep mutable state in separate types to preserve an isolation of duties. One particular class highlighted my problems – a wrapper around NWConnection that tracks additional state with it and handles the Automerge sync protocol. It was getting really darned inconvenient with the large number of await suspension points.

I slowly clued in that it would be a lot easier if that were all synchronous – and there was no reason it couldn’t be. In my ideal world, I’d have the type Repo (my top-level repository) as an non-global actor, and isolate any classes it used to the same isolation zone as that one, non-global, actor. I think that’s a capability that’s coming, or at least I wasn’t sure how to arrange that today with Swift 5.10. Instead I opted to make a single global actor for the library and switch what I previously set up as actors to classes isolated to that global actor.

That let me simplify quite a bit, notably when dealing with the state of connections within a network adapter. What surprised me was that when I switched from Actor to isolated class, there were few warnings from the change. The changes were mostly warnings that calls dropped back to synchronous, and no longer needed await. That was quick to fix up; the change to isolated classes was much faster and easier than I anticipated. After I made the initial changes, I went through the various initializers and associated configuration calls to make more of it explicitly synchronous. The end result was more code that could be set up (initialized) without an async context. And finally, I updated how I handled the networking so that as I needed to track state, I didn’t absolutely have to use the async algorithsm library.

A single global actor?

A bit of a side note: I thought about making Repo a global actor, but I prefer to not demand a singleton style library for it’s usage. That choice made it much easier to host multiple repositories when it came time to run functional tests with a mock In-Memory network, or integration tests with the actual providers. I’m still a slight bit concerned that I might be adding to a long-term potential proliferation of global actors from libraries – but it seems like the best solution at the moment. I’d love it if I could do something that indicated “All these things need a single isolation domain, and you – developer – are responsible for providing one that fits your needs”. I’m not sure that kind of concept is even on the table for future work.

Recipes for solving these problems

If you weren’t already aware of it, Matt Massicotte created a GitHub repository called ConcurrencyRecipes. This is a gemstone of knowledge, hints, and possible solutions. I leaned into it again and again while building (and rebuilding) this library. One of the “convert it to async” challenges I encountered was providing an async interface to my own peer-to-peer network protocol. I built the protocol using the Network framework based (partially on Apple’s sample code), which is all synchronous code and callbacks. A high level, I wanted it to act similarly URLSessionWebSocketTask. This gist being a connection has an async send() and an async receive() for sending and receiving messages on the connection. With an async send and receive, you can readily assemble several different patterns of access.

To get there, I used a combination of CheckedContinuation (both the throwing and non-throwing variations) to work with what NWConnection provided. I wish that was better documented. How to properly use those APIs is opaque, but that is a digression for another time. I’m particular happy with how my code worked out, including adding a method on the PeerConnection class that used structured concurrency to handle a timeout mechanism.

Racing tasks with structured concurrency

One of the harder warnings for me to understand was related to racing concurrent tasks in order to create an async method with a “timeout”. I stashed a pattern for how to do this in my notebook with references to Beyond the basics of structured concurrency from WWDC23.

If the async task returns a value, you can set it up something like this (this is from PeerToPeerConnection.swift):

let msg = try await withThrowingTaskGroup(of: SyncV1Msg.self) { group in
    group.addTask {
        // retrieve the next message
        try await self.receiveSingleMessage()
    }
    group.addTask {
        // Race against the receive call with a continuous timer
        try await Task.sleep(for: explicitTimeout)
        throw SyncV1Msg.Errors.Timeout()
    }
    guard let msg = try await group.next() else {
        throw CancellationError()
    }
    // cancel all ongoing tasks (the websocket receive request, in this case)
    group.cancelAll()
    return msg
}

There’s a niftier version available in Swift 5.9 (which I didn’t use) for when you don’t care about the return value:

func run() async throws {
    try await withThrowingDiscardingTaskGroup { group in
        for cook in staff.keys {
            group.addTask { try await cook.handleShift() }
        }
        group.addTask { // keep the restaurant going until closing time
            try await Task.sleep(for: shiftDuration)
            throw TimeToCloseError()
        }
    }
}

With Swift 5.10 compiler, my direct use of this displayed a warning:

warning: passing argument of non-sendable type 'inout ThrowingTaskGroup<SyncV1Msg, any Error>' outside of global actor 'AutomergeRepo'-isolated context may introduce data races
guard let msg = try await group.next() else {
                          ^

I didn’t really understand the core of this warning, so I asked on the Swift forums. VNS (on the forums) had run into the same issue and helped explain it:

It’s because withTaskGroup accepts a non-Sendable closure, which means the closure has to be isolated to whatever context it was formed in. If your test() function is nonisolated, it means the closure is nonisolated, so calling group.waitForAll() doesn’t cross an isolation boundary.

The workaround to handle the combination of non-sendable closures and TaskGroup is to make the async method that runs this code nonisolated. In the context I was using it, the class that contains this method is isolated to a global actor, so it’s inheriting that context. By switching the method to be explicitly non-isolated, the compiler doesn’t complain about group being isolated to that global actor.

Sharing information back to SwiftUI

These components have all sorts of interesting internal state, some of which I wanted to export. For example, to provide information from the network providers to make a user interface (in SwiftUI). I want to be able to choose to connect to endpoints, to share what endpoints might be available (from the NWBrowser embedded in the peer to peer network provider), and so forth.

I first tried to lean into AsyncStreams. While they make a great local queue for a single point to point connection, I found they were far less useful to generally make a firehouse of data that SwiftUI knows how to read and react to. While I tried to use all the latest techniques, to handle this part I went to my old friend Combine. Some people are effusing that Combine is dead and dying – but boy it works. And most delightfully, you can have any number of endpoints pick up and subscribe to a shared publisher, which was perfect for my use case. Top that off with SwiftUI having great support to receive streams of data from Combine, and it was an easy choice.

I ended up using Combine publishers to make a a few feeds of data from the PeerToPeerProvider. They share information about what other peers were available, the current state of the listener (that accepts connections) and the browser (that looks for peers), and last a publisher that provides information about active peer to peer connctions. I feel that worked out extremely well. It worked so well that I made an internal publisher (not exposed via the public API) for tests to get events and state updates from within a repository.

Integration Testing

It’s remarkably hard to usefully unit test network providers. Instead of unit testing, I made a separate Swift project for the purposes of running integration tests. It sits in it’s own directory in the git repository and references automerge-repo-swift as a local dependency. A side effect is that it let me add in all sorts of wacky dependencies that were handy for the integration testing, but that I really didn’t want exposed and transitive for the main package. I wish that Swift Packages had a means to identify test-only dependencies that didn’t propagate to other packages for situations like this. Ah well, my solution was a separate sub-project.

Testing using the Combine publisher worked well. Although it took a little digging to figure out the correct way to set up and use expectations with async XCTests. It feels a bit exhausting to assemble the expectations and fulfillment calls, but its quite possible to get working. If you want to see this in operation, take a look at P2P+explicitConnect.swift. I started to look at potentially using the upcoming swift-testing, but with limited Swift 5.10 support, I decided to hold off for now. If it makes asynchronous testing easier down the road, I may well adopt it quickly after it’s initial release.

The one quirky place that I ran into with that API setup was that expectation.fulfill() gets cranky with you if you call it more than once. My publisher wasn’t quite so constrained with state updates, so I ended up cobbling a boolean latch variable in a sink when I didn’t have a sufficiently constrained closure.

The other quirk in integration testing is that while it works beautifully on a local machine, I had a trouble getting it to work in CI (using GitHub Actions). Part of the issue is that the current swift test defaults to running all possible tests at once, in parallel. Especially for integration testing of peer to peer networking, that meant a lot of network listeners, and browsers, getting shoved together at once on the local network. I wrote a script to list out the tests and run them one at a time. Even breaking it down like that didn’t consistently get through CI. I also tried higher wait times (120 seconds) on the expectations. When I run them locally, most of those tests take about 5 seconds each.

The test that was a real challenge was the cross-platform one. Automerge-repo has a sample sync server (NodeJS, using Automerge through WASM). I created a docker container for it, and my cross-platform integration test pushes and pulls documents to an instance that I can run in Docker. Well… Docker isn’t available for macOS runners, so that’s out for GitHub Actions. I have a script that spins up a local docker instance, and I added a check into the WebSocket network provider test – if it couldn’t find a local instance to work against, it skips the test.

Final Takeaways

Starting with a plan for isolating state made the choices of how and what I used a bit easier, and reaching for global-actor constrained classes made synchronous use of those classes much easier. For me, this mostly played out in better (synchronous) intializers and dealing with collections using functional programming patterns.

I hope there’s some planning/thinking in SwiftUI to update or extend the app structure to accomodate async hooks for things like setup and initialization (FB9221398). That should make it easier for a developer to run an async initializer and verify that it didn’t fail, before continuing into the normal app lifecycle. Likewise, I hope that the Document-based APIs gain an async-context to work with documents to likewise handle asynchronous tasks (FB12243722). Both of these spots are very awkward places for me.

Once you shift to using asynchronous calls, it can have a ripple effect in your code. If you’re looking at converting existing code, start at the “top” and work down. That helped me to make sure there weren’t secondary complications with that choice (such as a a need for an async initializer).

Better yet, step back and take the time to identify where mutable state exists. Group it together as best you can, and review how you’re interacting it, and in what isolation region. In the case of things that need to be available to SwiftUI, you can likely isolate methods appropriately (*cough* MainActor *cough*). Then make the parts you need to pass between isolation domains Sendable. Recognize that in some cases, it may be fine to do the equivalent of “Here was the state at some recent moment, if you might want to react to that”. There are several places where I pass back a summary snapshot of mutable state to SwiftUI to use in UI elements.

And do yourself a favor and keep Matt’s Concurrency Recipes on speed-dial.

Before I finished this post, I listened to episode 43 of the Swift Package Index podcast. It’s a great episode, with Holly Bora, compiler geek and manager of the Swift language team, on as a guest to talk about the Swift 6. A tidbit she shared was that they are creating a Swift 6 migration guide, to be published on the swift.org website. Something to look forward to, in addition to Matt’s collection of recipes!

{
"by": "ingve",
"descendants": 0,
"id": 40246694,
"score": 1,
"time": 1714737688,
"title": "Designing a Swift library with data-race safety",
"type": "story",
"url": "https://rhonabwy.com/2024/04/29/designing-a-swift-library-with-data-race-safety/"
}
{
"author": "View more posts",
"date": "2024-04-29T15:21:01.000Z",
"description": "I cut an initial release (0.1.0-alpha) of the library automerge-repo-swift. A supplemental library to Automerge swift, it adds background networking for sync and storage capabilities. The library e…",
"image": "https://s0.wp.com/i/blank.jpg",
"logo": "https://logo.clearbit.com/rhonabwy.com",
"publisher": "Rhonabwy",
"title": "Designing a Swift library with data-race safety",
"url": "https://rhonabwy.com/2024/04/29/designing-a-swift-library-with-data-race-safety/"
}
{
"url": "https://rhonabwy.com/2024/04/29/designing-a-swift-library-with-data-race-safety/",
"title": "Designing a Swift library with data-race safety",
"description": "I cut an initial release (0.1.0-alpha) of the library automerge-repo-swift. A supplemental library to Automerge swift, it adds background networking for sync and storage capabilities. The library extends code I initially created in the Automerge demo app (MeetingNotes), and was common enough to warrant its own library. While I was extracting those pieces, I leaned…",
"links": [
"https://rhonabwy.com/2024/04/29/designing-a-swift-library-with-data-race-safety/",
"https://wp.me/p5Pn4v-1Jt"
],
"image": "https://s0.wp.com/i/blank.jpg",
"content": "<div>\n<p>I cut an initial release (<a target=\"_blank\" href=\"https://github.com/automerge/automerge-repo-swift/releases/tag/0.1.0-alpha\">0.1.0-alpha</a>) of the library <a target=\"_blank\" href=\"https://github.com/automerge/automerge-repo-swift/\">automerge-repo-swift</a>. A supplemental library to <a target=\"_blank\" href=\"https://github.com/automerge/automerge-swift/\">Automerge swift</a>, it adds background networking for sync and storage capabilities. The library extends code I initially created in the Automerge demo app (<a target=\"_blank\" href=\"http://github.com/automerge/MeetingNotes\">MeetingNotes</a>), and was common enough to warrant its own library. While I was extracting those pieces, I leaned into the same general pattern that was used in the Javascript library <a target=\"_blank\" href=\"http://github.com/automerge/automerge-repo\">automerge-repo</a>. That library provides largely the same functionality for Automerge in javascript. I borrowed the public API structure, as well as compatibility and implementation details for the Automerge sync protocol. One of my goals while assembling this new library was to build it fully compliant with Swift’s data-race safety. Meaning that it compiles without warnings when I use the Swift compiler’s <a target=\"_blank\" href=\"https://www.swift.org/documentation/concurrency/\">strict-concurrency</a> mode.</p>\n<p>There were some notable challenges in coming up to speed with the concepts of isolation and sendability. In addition to learning the concepts, how to apply them is an open question. Not many Swift developers have embraced strict concurrency and talked about the trade-offs or implications for choices. Because of that, I feel that there’s relatively little available knowledge to understand the trade-offs to make when you protect mutable state. This post shares some of the stumbling blocks I hit, choices I made, and lessons I’ve learned. My hope is that it helps other developers facing a similar challenge.</p>\n<h2>Framing the problem</h2>\n<p>The way I try to learn and apply new knowledge to solve these kinds of “new fangled” problems is first working out <strong><em>how</em></strong> to think about the problem. I’ve not come up with a good way to ask other people how to do that. I think when I frame the problem with good first-principles in mind, trade-offs in solutions become easier to understand. Sometimes the answers are even self-obvious.</p>\n<p>The foremost principle in strict-concurrency is “protect your mutable state”. The compiler warnings give you feedback about potential hazards and data-races. In Swift, protecting the state uses a concept of an “isolation domain”. My layman’s take on isolation is “How can the compiler verify that only one thread is accessing this bit of data at a time”. There are some places where the compiler infers the state of isolation, and some of them still changing as we progress towards Swift 6. When you’re writing code, the compiler knows what is isolated (and non-isolated) – either by itself or based on what you annotated. When the compiler infers an isolation domain, that detail is not (yet?) easily exposed to developers. It really only shows up when there’s a mismatch in your assumptions vs. what the compiler thinks and it issues a strict-concurrency warning.</p>\n<p>Sendability is the second key concept. In my layman’s terms again, something that is sendable is safe to cross over thread boundaries. With Swift 5.10, the compiler has enough knowledge of types to be able to make guarantees about what is safe, and what isn’t.</p>\n<p>The first thing I did was lean heavily into making anything and everything <code>Sendable</code>. In hindsight, that was a bit of a mistake. Not disastrous, but I made a lot more work for myself. Not everything <em>needs</em> to be sendable. Taking advantage of isolation, it is fine – sometimes notably more efficient and easier to reason about – to have and use non-sendable types within an isolation domain. More on that in a bit.</p>\n<p>My key to framing up the problem was to think in terms of making explicit choices about what data should be in an isolation region along with how I want to pass information from one isolation domain to another. Any types I pass (generally) need to be Sendable, and anything that stays within an isolation domain doesn’t. For this library, I have a lot of mutable state: networking connections, updates from users, and a state machines coordinating it all. All of it needed so a repository can store and synchronize Automerge documents. Automerge documents themselves are Sendable (I had that in place well before starting this work). I made the Automerge documents sendable by wrapping access and updates to anything mutable within a serial dispatch queue. (This was also needed because the core Automerge library – a Rust library accessed through FFI – was not safe for multi-threaded use).</p>\n<h2>Choosing Isolation</h2>\n<p>I knew I wanted to make at least one explicit isolation domain, so the first question was “Actor or isolated class?” Honestly, I’m still not sure I understand all the tradeoffs. Without knowing what the effect would be to start off with, I decided to pick “let’s use actors everywhere” and see how it goes. Some of the method calls in the design of the Automerge repository were easily and obviously async, so that seemed like a good first cut. I made the top-level repo an actor, and then I kept making any internal type that had mutable state also be it’s own actor. That included a storage subsystem and a network subsystem, both of which I built to let someone else provide the network or storage provider external to this project. To support external plugins that work with this library, I created protocols for the <a target=\"_blank\" href=\"https://swiftpackageindex.com/automerge/automerge-repo-swift/0.1.0-alpha/documentation/automergerepo/storageprovider\">storage</a> and <a target=\"_blank\" href=\"https://swiftpackageindex.com/automerge/automerge-repo-swift/0.1.0-alpha/documentation/automergerepo/networkprovider\">network</a> provider, as well as <a target=\"_blank\" href=\"https://swiftpackageindex.com/automerge/automerge-repo-swift/0.1.0-alpha/documentation/automergerepo/networkeventreceiver\">one that the network providers use</a> to talk back to the repository.</p>\n<p>The downside of that choice was two-fold – first setting things up, then interacting with it from within a SwiftUI app. Because I made every-darn-thing an actor, I hade to await a response, which meant a lot of potential suspension points in my code. That also propagated to imply even setup needed to be done within an async context. Sometimes that’s easy to arrange, but other times it ends up being a complete pain in the butt. More specifically, quite a few of the current Apple-provided frameworks don’t have or provide a clear path to integrate async setup hooks. The server-side Swift world has a lovely “set up and run” mechanism (<a target=\"_blank\" href=\"https://github.com/swift-server/swift-service-lifecycle\">swift-service-lifecycle</a>) it is adopting, but Apple hasn’t provided a similar concept the frameworks it provides. The one that bites me most frequently is the SwiftUI app and document-based app lifecycle, which are all synchronous.</p>\n<h2>Initialization Challenges</h2>\n<p>Making the individual actors – <a target=\"_blank\" href=\"https://swiftpackageindex.com/automerge/automerge-repo-swift/0.1.0-alpha/documentation/automergerepo/repo\">Repo</a> and the two network providers I created – initializable with synchronous calls wasn’t too bad. The stumbling block I hit (that I still don’t have a great solution to) was when I wanted to add and activate the network providers to a repository. To arrange that, I’m currently using a detached Task that I kick off in the SwiftUI App’s initializer:</p>\n<pre><code>public let repo = Repo(sharePolicy: .agreeable)\npublic let websocket = WebSocketProvider()\npublic let peerToPeer = PeerToPeerProvider(\n PeerToPeerProviderConfiguration(\n passcode: \"AutomergeMeetingNotes\",\n reconnectOnError: true,\n autoconnect: false\n )\n)\n@main\nstruct MeetingNotesApp: App {\n var body: some Scene {\n DocumentGroup {\n MeetingNotesDocument()\n } editor: { file in\n MeetingNotesDocumentView(document: file.document)\n }\n .commands {\n CommandGroup(replacing: CommandGroupPlacement.toolbar) {\n }\n }\n }\n init() {\n Task {\n await repo.addNetworkAdapter(adapter: websocket)\n await repo.addNetworkAdapter(adapter: peerToPeer)\n }\n }\n}</code></pre>\n<h2>Swift Async Algorithms</h2>\n<p>One of the lessons I’ve learned is that if you find yourself stashing a number of actors into an array, and you’re used to interacting with them using functional methods (filter, compactMap, etc), you need to deal with the asynchronous access. The standard library built-in functional methods are all synchronous. Because of that, you can only access non-isolated properties on the actors. For me, that meant working with non-mutable state that I set up during actor initialization.</p>\n<p>The second path (and I went there) was to take on a dependency to <a target=\"_blank\" href=\"https://github.com/apple/swift-async-algorithms\">swift-async-algorithms</a>, and use its async variations of the functional methods. They let you “await” results for anything that needs to cross isolation boundaries. And because it took me an embarrasingly long time to figure it out: If you have an array of actors, the way to get to an AsyncSequence of them is to use the async property on the array after you’ve imported <code>swift-async-algorithms</code>. For example, something like the following snippet:</p>\n<pre><code>let arrayOfActors: [YourActorType] = []\nlet filteredResults = arrayOfActors.async.filter(...)</code></pre>\n<h2>Rethinking the isolation choice</h2>\n<p>That is my first version of this library. I got it functional, then turned around and tore it apart again. In making everything an actor, I was making LOTS of little isolation regions that the code had to hop between. With all the suspension points, that meant a lot of possible re-ordering of what was running. I had to be extrodinarily careful not to assume a copy of some state I’d nabbed earlier was still the same after the await. (I still have to be, but it was a more prominent issue with lots of actors.) All of this boils down to being aware of actor re-entrancy, and when it might invalidate something.</p>\n<p>I knew that I wanted at least one isolation region (the repository). I also want to keep mutable state in separate types to preserve an isolation of duties. One particular class highlighted my problems – a wrapper around <a target=\"_blank\" href=\"https://developer.apple.com/documentation/network/nwconnection\">NWConnection</a> that tracks additional state with it and handles the Automerge sync protocol. It was getting really darned inconvenient with the large number of <code>await</code> suspension points.</p>\n<p>I slowly clued in that it would be a lot easier if that were all synchronous – and there was no reason it couldn’t be. In my ideal world, I’d have the type <code>Repo</code> (my top-level repository) as an non-global actor, and isolate any classes it used to the same isolation zone as that one, non-global, actor. I think that’s a capability that’s coming, or at least I wasn’t sure how to arrange that today with Swift 5.10. Instead I opted to make <a target=\"_blank\" href=\"https://swiftpackageindex.com/automerge/automerge-repo-swift/0.1.0-alpha/documentation/automergerepo/automergerepo\">a single global actor for the library</a> and switch what I previously set up as actors to classes isolated to that global actor.</p>\n<p>That let me simplify quite a bit, notably when dealing with the state of connections within a network adapter. What surprised me was that when I switched from Actor to isolated class, there were few warnings from the change. The changes were mostly warnings that calls dropped back to synchronous, and no longer needed <code>await</code>. That was quick to fix up; the change to isolated classes was much faster and easier than I anticipated. After I made the initial changes, I went through the various initializers and associated configuration calls to make more of it explicitly synchronous. The end result was more code that could be set up (initialized) without an async context. And finally, I updated how I handled the networking so that as I needed to track state, I didn’t absolutely have to use the async algorithsm library.</p>\n<h3>A single global actor?</h3>\n<p>A bit of a side note: I thought about making <code>Repo</code> a global actor, but I prefer to not demand a singleton style library for it’s usage. That choice made it much easier to host multiple repositories when it came time to run functional tests with a mock In-Memory network, or integration tests with the actual providers. I’m still a slight bit concerned that I might be adding to a long-term potential proliferation of global actors from libraries – but it seems like the best solution at the moment. I’d love it if I could do something that indicated “All these things need a single isolation domain, and you – developer – are responsible for providing one that fits your needs”. I’m not sure that kind of concept is even on the table for future work.</p>\n<h2>Recipes for solving these problems</h2>\n<p>If you weren’t already aware of it, Matt Massicotte created a GitHub repository called <a target=\"_blank\" href=\"https://github.com/mattmassicotte/ConcurrencyRecipes\">ConcurrencyRecipes</a>. This is a gemstone of knowledge, hints, and possible solutions. I leaned into it again and again while building (and rebuilding) this library. One of the “convert it to async” challenges I encountered was providing an async interface to my own peer-to-peer network protocol. I built the protocol using the <a target=\"_blank\" href=\"https://developer.apple.com/documentation/network\">Network framework</a> based (partially on Apple’s sample code), which is all synchronous code and callbacks. A high level, I wanted it to act similarly <a target=\"_blank\" href=\"https://developer.apple.com/documentation/foundation/urlsessionwebsockettask\">URLSessionWebSocketTask</a>. This gist being a connection has an<code> async send()</code> and an <code>async receive()</code> for sending and receiving messages on the connection. With an async <code>send</code> and <code>receive</code>, you can readily assemble several different patterns of access.</p>\n<p>To get there, I used a combination of <a target=\"_blank\" href=\"https://developer.apple.com/documentation/swift/checkedcontinuation\">CheckedContinuation</a> (both the throwing and non-throwing variations) to work with what <code>NWConnection</code> provided. I wish that was better documented. How to properly use those APIs is opaque, but that is a digression for another time. I’m particular happy with how my code worked out, including adding a method on the <a target=\"_blank\" href=\"https://github.com/automerge/automerge-repo-swift/blob/main/Sources/AutomergeRepo/Networking/Providers/PeerToPeerConnection.swift\">PeerConnection class</a> that used structured concurrency to handle a timeout mechanism.</p>\n<h2>Racing tasks with structured concurrency</h2>\n<p>One of the harder warnings for me to understand was related to racing concurrent tasks in order to create an async method with a “timeout”. I stashed a pattern for how to do this in my notebook with references to <a target=\"_blank\" href=\"https://developer.apple.com/wwdc23/10170\">Beyond the basics of structured concurrency</a> from WWDC23.</p>\n<p>If the async task returns a value, you can set it up something like this (<a target=\"_blank\" href=\"https://github.com/automerge/automerge-repo-swift/blob/main/Sources/AutomergeRepo/Networking/Providers/PeerToPeerConnection.swift#L259\">this is from PeerToPeerConnection.swift</a>):</p>\n<pre><code>let msg = try await withThrowingTaskGroup(of: SyncV1Msg.self) { group in\n group.addTask {\n // retrieve the next message\n try await self.receiveSingleMessage()\n }\n group.addTask {\n // Race against the receive call with a continuous timer\n try await Task.sleep(for: explicitTimeout)\n throw SyncV1Msg.Errors.Timeout()\n }\n guard let msg = try await group.next() else {\n throw CancellationError()\n }\n // cancel all ongoing tasks (the websocket receive request, in this case)\n group.cancelAll()\n return msg\n}</code></pre>\n<p>There’s a niftier version available in Swift 5.9 (which I didn’t use) for when you don’t care about the return value:</p>\n<pre><code>func run() async throws {\n try await withThrowingDiscardingTaskGroup { group in\n for cook in staff.keys {\n group.addTask { try await cook.handleShift() }\n }\n group.addTask { // keep the restaurant going until closing time\n try await Task.sleep(for: shiftDuration)\n throw TimeToCloseError()\n }\n }\n}</code></pre>\n<p>With Swift 5.10 compiler, my direct use of this displayed a warning:</p>\n<pre><code>warning: passing argument of non-sendable type 'inout ThrowingTaskGroup&lt;SyncV1Msg, any Error&gt;' outside of global actor 'AutomergeRepo'-isolated context may introduce data races\nguard let msg = try await group.next() else {\n ^</code></pre>\n<p>I didn’t really understand the core of this warning, so <a target=\"_blank\" href=\"https://forums.swift.org/t/help-understanding-a-swift-5-10-compiler-warning-when-racing-two-async-tasks-to-arrange-a-timeout-mechanism-using-structured-concurrency/71475/2\">I asked on the Swift forums</a>. VNS (on the forums) had run into the same issue and helped explain it:</p>\n<blockquote>\n<p>It’s because <code>withTaskGroup</code> accepts a non-<code>Sendable</code> closure, which means the closure has to be isolated to whatever context it was formed in. If your <code>test()</code> function is <code>nonisolated</code>, it means the closure is <code>nonisolated</code>, so calling <code>group.waitForAll()</code> doesn’t cross an isolation boundary.</p>\n</blockquote>\n<p>The workaround to handle the combination of non-sendable closures and TaskGroup is to make the async method that runs this code <code>nonisolated</code>. In the context I was using it, the class that contains this method is isolated to a global actor, so it’s inheriting that context. By switching the method to be explicitly non-isolated, the compiler doesn’t complain about <code>group</code> being isolated to that global actor.</p>\n<h2>Sharing information back to SwiftUI</h2>\n<p>These components have all sorts of interesting internal state, some of which I wanted to export. For example, to provide information from the network providers to make a user interface (in SwiftUI). I want to be able to choose to connect to endpoints, to share what endpoints might be available (from the <a target=\"_blank\" href=\"https://developer.apple.com/documentation/network/nwbrowser\">NWBrowser</a> embedded in the peer to peer network provider), and so forth.</p>\n<p>I first tried to lean into <a target=\"_blank\" href=\"https://developer.apple.com/documentation/swift/asyncstream\">AsyncStreams</a>. While they make a great local queue for a single point to point connection, I found they were far less useful to generally make a firehouse of data that SwiftUI knows how to read and react to. While I tried to use all the latest techniques, to handle this part I went to <a target=\"_blank\" href=\"https://heckj.github.io/swiftui-notes/\">my old friend Combine</a>. Some people are effusing that Combine is dead and dying – but boy it works. And most delightfully, you can have any number of endpoints pick up and subscribe to a shared publisher, which was perfect for my use case. Top that off with SwiftUI having great support <a target=\"_blank\" href=\"https://developer.apple.com/documentation/swiftui/view/onreceive(_:perform:)\">to receive streams of data</a> from Combine, and it was an easy choice.</p>\n<p>I ended up using Combine publishers to make a a few feeds of data from the <a target=\"_blank\" href=\"https://swiftpackageindex.com/automerge/automerge-repo-swift/0.1.0-alpha/documentation/automergerepo/peertopeerprovider\">PeerToPeerProvider</a>. They share information about <a target=\"_blank\" href=\"https://swiftpackageindex.com/automerge/automerge-repo-swift/0.1.0-alpha/documentation/automergerepo/peertopeerprovider/availablepeerpublisher\">what other peers were available</a>, the <a target=\"_blank\" href=\"https://swiftpackageindex.com/automerge/automerge-repo-swift/0.1.0-alpha/documentation/automergerepo/peertopeerprovider/listenerstatepublisher\">current state of the listener</a> (that accepts connections) and <a target=\"_blank\" href=\"https://swiftpackageindex.com/automerge/automerge-repo-swift/0.1.0-alpha/documentation/automergerepo/peertopeerprovider/browserstatepublisher\">the browser</a> (that looks for peers), and last <a target=\"_blank\" href=\"https://swiftpackageindex.com/automerge/automerge-repo-swift/0.1.0-alpha/documentation/automergerepo/peertopeerprovider/connectionpublisher\">a publisher that provides information about active peer to peer connctions</a>. I feel that worked out extremely well. It worked so well that I made an <a target=\"_blank\" href=\"https://github.com/automerge/automerge-repo-swift/blob/main/Sources/AutomergeRepo/Repo.swift#L48\">internal publisher</a> (not exposed via the public API) for tests to get events and state updates from within a repository.</p>\n<h2>Integration Testing</h2>\n<p>It’s remarkably hard to <em>usefully</em> unit test network providers. Instead of unit testing, I made a separate Swift project for the purposes of running integration tests. It sits in it’s own directory in the git repository and references <code>automerge-repo-swift</code> as a local dependency. A side effect is that it let me add in all sorts of <a target=\"_blank\" href=\"https://rhonabwy.com/2024/04/02/distributed-tracing-with-testing-on-ios-and-macos/\">wacky dependencies</a> that were handy for the integration testing, but that I <strong><em>really</em></strong> didn’t want exposed and transitive for the main package. I wish that Swift Packages had a means to identify test-only dependencies that didn’t propagate to other packages for situations like this. Ah well, my solution was a separate sub-project.</p>\n<p>Testing using the Combine publisher worked well. Although it took a little digging to figure out the correct way to set up and use expectations with async XCTests. It feels a bit exhausting to assemble the expectations and fulfillment calls, but its quite possible to get working. If you want to see this in operation, take a look at <a target=\"_blank\" href=\"https://github.com/automerge/automerge-repo-swift/blob/main/IntegrationTests/Tests/IntegrationTestsTests/P2P%2BexplicitConnect.swift\">P2P+explicitConnect.swift</a>. I started to look at potentially using the upcoming <a target=\"_blank\" href=\"https://github.com/apple/swift-testing\">swift-testing</a>, but with limited Swift 5.10 support, I decided to hold off for now. If it makes asynchronous testing easier down the road, I may well adopt it quickly after it’s initial release.</p>\n<p>The one quirky place that I ran into with that API setup was that <code>expectation.fulfill()</code> gets cranky with you if you call it more than once. My publisher wasn’t quite so constrained with state updates, so I ended up cobbling a boolean latch variable in a <code>sink</code> when I didn’t have a sufficiently constrained closure.</p>\n<p>The other quirk in integration testing is that while it works beautifully on a local machine, I had a trouble getting it to work in CI (using GitHub Actions). Part of the issue is that the current <code>swift test</code> defaults to running all possible tests at once, in parallel. Especially for integration testing of peer to peer networking, that meant a lot of network listeners, and browsers, getting shoved together at once on the local network. I wrote a script to list out the tests and run them one at a time. Even breaking it down like that didn’t consistently get through CI. I also tried higher wait times (120 seconds) on the expectations. When I run them locally, most of those tests take about 5 seconds each.</p>\n<p>The test that was a real challenge was the cross-platform one. Automerge-repo has a <a target=\"_blank\" href=\"https://github.com/automerge/automerge-repo-sync-server\">sample sync server</a> (NodeJS, using Automerge through WASM). I created a docker container for it, and my cross-platform integration test pushes and pulls documents to an instance that I can run in Docker. Well… Docker isn’t available for macOS runners, so that’s out for GitHub Actions. I have a script that spins up a local docker instance, and I added a check into the WebSocket network provider test – if it couldn’t find a local instance to work against, it skips the test.</p>\n<h2>Final Takeaways</h2>\n<p>Starting with a plan for isolating state made the choices of how and what I used a bit easier, and reaching for global-actor constrained classes made synchronous use of those classes much easier. For me, this mostly played out in better (synchronous) intializers and dealing with collections using functional programming patterns.</p>\n<p>I hope there’s some planning/thinking in SwiftUI to update or extend the app structure to accomodate async hooks for things like setup and initialization (FB9221398). That should make it easier for a developer to run an async initializer <strong><em>and</em></strong> verify that it didn’t fail, before continuing into the normal app lifecycle. Likewise, I hope that the Document-based APIs gain an async-context to work with documents to likewise handle asynchronous tasks (FB12243722). Both of these spots are very awkward places for me.</p>\n<p>Once you shift to using asynchronous calls, it can have a ripple effect in your code. If you’re looking at converting existing code, start at the “top” and work down. That helped me to make sure there weren’t secondary complications with that choice (such as a a need for an async initializer).</p>\n<p>Better yet, step back and take the time to identify where mutable state exists. Group it together as best you can, and review how you’re interacting it, and in what isolation region. In the case of things that need to be available to SwiftUI, you can likely isolate methods appropriately (<em>*cough*</em> <code><strong>MainActor</strong></code> <em>*cough*</em>). Then make the parts you need to pass between isolation domains <code>Sendable</code>. Recognize that in some cases, it may be fine to do the equivalent of “Here was the state at some recent moment, if you might want to react to that”. There are several places where I pass back a summary snapshot of mutable state to SwiftUI to use in UI elements.</p>\n<p>And do yourself a favor and keep Matt’s <a target=\"_blank\" href=\"https://github.com/mattmassicotte/ConcurrencyRecipes\">Concurrency Recipes</a> on speed-dial. </p>\n<figure><blockquote><p>Before I finished this post, I listened to <a target=\"_blank\" href=\"https://share.transistor.fm/s/2d4b1ba7\">episode 43 of the Swift Package Index podcast</a>. It’s a great episode, with Holly Bora, compiler geek and manager of the Swift language team, on as a guest to talk about the Swift 6. A tidbit she shared was that they are creating a Swift 6 migration guide, to be published on the <a target=\"_blank\" href=\"https://www.swift.org/\">swift.org website</a>. Something to look forward to, in addition to Matt’s collection of recipes!</p></blockquote></figure>\n\t</div>",
"author": "@heckj",
"favicon": "https://s1.wp.com/i/favicon.ico",
"source": "rhonabwy.com",
"published": "2024-04-29T15:21:01+00:00",
"ttr": 730,
"type": "article"
}