Hi everyone,
The last couple of days, I've been working on a testing setup for driving a Swift-NIO pipeline. The idea is to send stuff in on one side, and assert everything that comes out for its (simulated) clients on the other sides.
Here's an example from my MUD game where when a person SAYs something, the other occupants in the room receive the message.
@Test func `listener receives say message`() async throws {
let speaker = try await makeUser(in: world.userRepository)
let listener = try await makeUser(in: world.userRepository)
// makeConnectedChannel is a helper that creates EmbeddedChannels
let channel1 = try makeConnectedChannel(
port: 1, world: world, sessionStorage: sessionStorage)
let channel2 = try makeConnectedChannel(
port: 2, world: world, sessionStorage: sessionStorage)
defer {
_ = try? channel1.finish(acceptAlreadyClosed: true)
_ = try? channel2.finish(acceptAlreadyClosed: true)
}
_ = try channel1.readOutbound(as: SSHChannelData.self) // discard welcome
_ = try channel2.readOutbound(as: SSHChannelData.self) // discard welcome
injectSession(user: speaker, channel: channel1, into: sessionStorage)
injectSession(user: listener, channel: channel2, into: sessionStorage)
try sendLine("SAY Hello", to: channel1)
// experimental Swift Testing feature: polling confirmations
try await confirmation(until: .firstPass) {
channel1.embeddedEventLoop.run()
channel2.embeddedEventLoop.run()
return try collectOutbound(from: channel2).contains {
$0.contains("\(speaker.username) says: Hello")
}
}
}
This test passes, but emits these warnings in STDOUT:
ERROR: NIO API misuse: EmbeddedEventLoop is not thread-safe. You can only use it from the thread you created it on. This problem will be upgraded to a forced crash in future versions of SwiftNIO.
Having the tests run on MainActor helps a bit, but it still happens. After some hunting I notice that they are (also) caused in the promise.completeWithTask function:
final class ParseHandler: ChannelInboundHandler, Sendable {
// stuff
public func channelRead(context: ChannelHandlerContext, data: NIOAny) {
// stuff
// `Context` does not conform to `@Sendable`, but `EventLoop` does,
// so we pass only the EventLoop and a reference to the `fireChannelRead` function.
let eventLoop = context.eventLoop
let fireChannelReadInABox = NIOLoopBound(context.fireChannelRead, eventLoop: eventLoop)
promise.completeWithTask { [weak self] in
guard let self else {
return
}
// we need to go into async world because the backends use structured concurrency
let response = await self.createMudResponse(mudCommand: mudCommand)
eventLoop.execute {
fireChannelReadInABox.value(self.wrapInboundOut(response))
}
}
My questions are:
- first off, am I using the EmbeddedChannel in the intended way or is there another primitive more suited for these kinds of end-to-end tests?
- is EmbeddedChannel with promise.completeWithTask a supported combination?
- are there other/better patterns for writing end-to-end pipeline tests in a Swift-NIO context (that include a broadcast/multicast as well as a hop to an async function).
Thanks for your help!