Skip to content

TestChannel.send never fails, even after the channel has been shut down #4222

Description

@khajavi

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

  • zio-http-testkit: 3.11.1

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions