Summary
TestChannel.send (zio-http-testkit) always succeeds, even after the channel has been shut down. On a real (Netty-backed) WebSocketChannel, sending after the peer disconnects fails the Task (e.g. ClosedChannelException). This makes it impossible to test that a WebSocket handler correctly recovers from a failed channel.send using TestClient/TestChannel.
Originally reported on Discord: https://discord.com/channels/629491597070827530/819703129267372113/1514307860396507236
Reproduction
Standalone spec (uses published zio-http via zio-http-example-testing's own sbt project):
package example.testing
import zio._
import zio.http._
import zio.http.ChannelEvent.Read
import zio.test._
// Repro: TestChannel.send never fails, even after the channel has been shut down.
// On a real (Netty) connection, sending after the peer disconnects fails the Task
// (e.g. ClosedChannelException). TestChannel.send just does `out.offer(in).unit`,
// which is backed by Queue#offer : UIO[Boolean] and can never fail.
object TestChannelSendAfterShutdownRepro extends ZIOSpecDefault {
def spec = test("channel.send fails after shutdown on a real channel, but not on TestChannel") {
for {
resultPromise <- Promise.make[Nothing, Either[Throwable, Unit]]
server = Handler.webSocket[Any] { channel =>
for {
_ <- channel.receive // handshake complete event
_ <- channel.shutdown // simulate the peer/connection going away
result <- channel.send(Read(WebSocketFrame.text("should fail: channel is closed"))).either
_ <- resultPromise.succeed(result)
} yield ()
}
_ <- TestClient.installSocketApp(server)
_ <- ZIO.serviceWithZIO[Client](_.socket(Handler.webSocket[Any](_ => ZIO.unit)))
result <- resultPromise.await
} yield assertTrue(result.isLeft) // FAILS today: result is Right(()) — send after shutdown should have failed
}.provide(TestClient.layer, Scope.default)
}
Run: sbt "testOnly example.testing.TestChannelSendAfterShutdownRepro"
Actual result: test fails — result = Right(value = ()). Expected: Left(...), since the channel was already shut down before send was called.
Root cause
zio-http-testkit/src/main/scala/zio/http/TestChannel.scala:
case class TestChannel(
in: Queue[WebSocketChannelEvent],
out: Queue[WebSocketChannelEvent],
promise: Promise[Nothing, Unit],
) extends WebSocketChannel {
...
def send(in: WebSocketChannelEvent)(implicit trace: Trace): Task[Unit] =
out.offer(in).unit
...
def shutdown(implicit trace: Trace): UIO[Unit] =
in.offer(ChannelEvent.Unregistered) *>
out.offer(ChannelEvent.Unregistered) *>
promise.succeed(()).unit
}
send is typed Task[Unit] (matching the real WebSocketChannel, which genuinely can fail — see zio-http/jvm/.../netty/WebSocketChannel.scala, where send calls nettyChannel.writeAndFlush(...) and fails once the underlying Netty channel is closed). But TestChannel.send is implemented purely as out.offer(in).unit, backed by Queue#offer : UIO[Boolean] — it never inspects promise (the flag shutdown completes) and can never fail, no matter how dead the channel is.
Impact
Any test attempting to verify a WebSocket handler's error-recovery path for channel.send failing (e.g. after the peer disconnects) cannot do so with TestClient/TestChannel — send will silently succeed regardless of channel state, unlike production behavior.
Suggested fix
Have send/sendAll check promise.isDone (or race against promise.await) before offering to out, and fail with something like new java.nio.channels.ClosedChannelException if the channel has already been shut down — mirroring the failure semantics of the real Netty-backed channel.
Environment
Summary
TestChannel.send(zio-http-testkit) always succeeds, even after the channel has been shut down. On a real (Netty-backed)WebSocketChannel, sending after the peer disconnects fails theTask(e.g.ClosedChannelException). This makes it impossible to test that a WebSocket handler correctly recovers from a failedchannel.sendusingTestClient/TestChannel.Originally reported on Discord: https://discord.com/channels/629491597070827530/819703129267372113/1514307860396507236
Reproduction
Standalone spec (uses published
zio-httpviazio-http-example-testing's own sbt project):Run:
sbt "testOnly example.testing.TestChannelSendAfterShutdownRepro"Actual result: test fails —
result = Right(value = ()). Expected:Left(...), since the channel was already shut down beforesendwas called.Root cause
zio-http-testkit/src/main/scala/zio/http/TestChannel.scala:sendis typedTask[Unit](matching the realWebSocketChannel, which genuinely can fail — seezio-http/jvm/.../netty/WebSocketChannel.scala, wheresendcallsnettyChannel.writeAndFlush(...)and fails once the underlying Netty channel is closed). ButTestChannel.sendis implemented purely asout.offer(in).unit, backed byQueue#offer : UIO[Boolean]— it never inspectspromise(the flagshutdowncompletes) and can never fail, no matter how dead the channel is.Impact
Any test attempting to verify a WebSocket handler's error-recovery path for
channel.sendfailing (e.g. after the peer disconnects) cannot do so withTestClient/TestChannel—sendwill silently succeed regardless of channel state, unlike production behavior.Suggested fix
Have
send/sendAllcheckpromise.isDone(or race againstpromise.await) before offering toout, and fail with something likenew java.nio.channels.ClosedChannelExceptionif the channel has already been shut down — mirroring the failure semantics of the real Netty-backed channel.Environment