In an earlier piece about the message channel, I wrote that its actor-isolated dispatcher kept routing deterministic. Then I sent fifty messages through it, one after another, and watched them arrive as 0, 1, 6, 7, 2, 3.... None were lost. The bus had shipped with navigation actions, where push then pop is not the same as pop then push. The actor was still there, and so was the sentence I had written about it. I had looked at this code before, but apparently I had started reading at the point where messages reached the dispatcher.
An actor owned the handler registry and dispatch loop; AsyncChannel carried messages to it. Most producers only needed to report that something happened, so their send() method was non-blocking. Sending into AsyncChannel is async, though. The implementation kept its public method synchronous by starting a task for each call, and that task awaited the channel send. I had read that as an implementation detail. In the fifty-message test it meant fifty independent tasks were racing to reach the channel. Each call to send() returned before its message was enqueued, so the order in which the caller made those calls had never been established anywhere.
The actor protected the registry from concurrent mutation. It had no say in which of those tasks reached AsyncChannel first. I was revisiting the package for Swift 6; it had been pinned to Swift 5 mode, and marking handlers @Sendable turned one of its passing tests into a compiler error. The handler appended to a captured array while the test polled that array from another task. I had taken the test’s passing result as evidence of correct delivery, when the test itself was racing its handler. That made the old promises about ordering and completion worth measuring rather than carrying forward on faith.
The timing check on sendAsync() was stranger. Its contract said the call returned after its handlers completed. In a timed run, the first call returned as its handler began, roughly three hundred milliseconds before that handler finished. The second returned when the first handler finished. AsyncChannel resumed a sender when the loop took its message, not when that message’s handlers completed; the loop could take the next message only after the previous handlers were done. So the waiting was real, just attached to the wrong message. The old tests followed each awaited call with a polling wait for the expected result, hiding the mismatch.
I stopped launching a task merely to enqueue a message. An AsyncStream continuation can yield synchronously, so those fifty calls now enqueued fifty messages in order; the consumer still processed them asynchronously. This did mean buffering if producers outran handlers, but the old non-blocking method had accumulated suspended send tasks instead. The new queue established order at the entrance to the dispatch loop, which still started a fresh task for every handler invocation. The same handler could process the second message before the first. I gave each handler its own queue and one task to drain it. Different handlers could work at the same time; each individual handler now saw messages in send order.
The awaited send still needed to know when its own handlers had finished. My first answer was a small actor that counted completions and resumed the sender. It worked. Handlers finished on separate tasks, so a synchronized counter was not an unreasonable choice, and the fact that the channel now had more than one actor was not the problem by itself. What bothered me was that the dispatch loop had selected the handlers and knew how many replies to expect, while another object owned the count for that same message. I had introduced a second place that knew how far the send had progressed.
Completion could travel back as another command on the stream. The loop would keep the count beside the registry and resume the caller when all the selected handlers reported back. That made me look again at why the registry had an actor at all. Registration could happen from arbitrary callers while the loop read the handler dictionary, so in the original design the state really was shared. But those callers did not need direct access to the dictionary. They could enqueue a registration command, and the loop could apply it in order with sends and completions. An attachment made before a send would enter that same stream first. The registry would belong to the loop alone.
At first that looked like replacing the actor with the stream. The stream did impose an order on commands, and handlers still ran concurrently, but no handler touched the registry and registration no longer mutated it from a caller’s task. Only the consumer loop reached the dictionary. The actor had protected genuinely shared state in the old design; the separate registration path was what made it shared. An earlier lock draft had made the same assumption. With one owner, neither a lock nor an actor had anything to guard.
The fifty sequential sends then arrived in order on three trials. An awaited send returned after its own handlers exited, rather than when the loop took its message. These were the guarantees I had described before; the new tests measured them directly.
The old sentence about deterministic routing is still in the published piece. I can see why I wrote it: the registry was behind an actor, and the dispatch loop ran there too. I had stopped asking questions at the protected object. After this rewrite, the question I care about comes earlier: who outside the dispatch loop actually needs to change it?