Close socket frame is not received in tapir-zio-http-server

Hello.

I have a socket server using Tapir+ZIO HTTP

val createRoom: PublicEndpoint[
    (PlayerName, RoomName),
    Unit,
    Stream[Throwable, ClientMessage] => Stream[Throwable, ServerMessage],
    ZioStreams & WebSockets
  ] =
    root
      .in("create")
      .in(query[PlayerName]("playerName"))
      .in(query[RoomName]("roomName"))
      .get
      .out(webSocketBody[ClientMessage, CodecFormat.Json, ServerMessage, CodecFormat.Json](ZioStreams)(using ClientMessage.wsFrameCodec, ServerMessage.wsFrameCodec))
ApiEndpoints.createRoom.zServerLogic(createRoom)

def createRoom(playerName: PlayerName, roomName: RoomName): UIO[Stream[Throwable, ClientMessage] => Stream[Throwable, ServerMessage]] =
  for
    ...
  yield roomSocket(id, playerName, room)

def roomSocket(
    id: RoomId,
    player: PlayerName,
    room: Room
)(clientMessages: Stream[Throwable, ClientMessage]): Stream[Throwable, ServerMessage] =
  val handler = handleMessage(id, player, room.hub)
  clientMessages.tap(Console.printLine(_)).flatMap(handler)

I am using a custom codec for ClientMessage:

given wsFrameCodec: Codec[WebSocketFrame, ClientMessage, CodecFormat.Json] =
  val frameJsonCodec = Codec.textWebSocketFrame(using zioCodec[ClientMessage])
    
  Codec.fromDecodeAndMeta[WebSocketFrame, ClientMessage, CodecFormat.Json](CodecFormat.Json()) {
    case WebSocketFrame.Close(code, reason) => DecodeResult.Value(Leave(code, reason))
    case frame => frameJsonCodec.decode(frame)
  } {
    case Leave(code, reason) => WebSocketFrame.Close(code, reason)
    case msg => frameJsonCodec.encode(msg)
  }

Everything work fine except for ClientMessage.Leave. It seems to be correctly sent by the client and encoded as close frame but it is not received by the server in clientMessages. Adding a print in the right case clause of wsFrameCodec shows that the code in the match case is not executed.

Is this the expected behaviour or am I doing something wrong?

Hello,

I did some digging (next time, it would be great if you could provide a self-contained, runnable, example reproducing the problem), and it seems that zio-http never passes the close frame.

Normally, this type of use-cases are covered by .decodeCloseRequests:

webSocketBody[...](ZioStreams).decodeCloseRequests(true)

However, as the docs say:

/** Note: some interpreters ignore this setting.
    * @param d
    *   If `true`, [[WebSocketFrame.Close]] frames will be passed to the request codec for decoding (in server interpreters).
    */
  def decodeCloseRequests(d: Boolean)

Which seems to be the case here. More precisely, here is the code fragment which translates zio-ws-frames to tapir-ws-frames. I added a .tap there, and we never receive this case:

case Read(ZioWebSocketFrame.Close(status, reason))    => Some(SttpWebSocketFrame.Close(status, reason.getOrElse("")))

When the client closes the WS, the only event that is received is Unregistered (which is in the same hierarchy as Read), however it doesn’t contain the closure reason etc.

We might translate an Unregistered event to a Close as well, but it seems that a “proper” solution would be to fix this in zio-http. I think it should be fairly easy to create a reproducing “pure” zio-http example, showcasing that a zio.http.ChannelEvent.Read(zio.http.ebSocketFrame.Close(status, reason)) event is never received.

1 Like

Ah wait, I take that back, it’s not a problem in zio-http but a matter of configuration :slight_smile:

So there is a piece of configuration of zio-http WebSockets, which specifies if close frames should be propagated. From tapir, it can be customised as follows:

ZioHttpInterpreter(
      ZioHttpServerOptions
        .default[Any]
        .withCustomWebSocketConfig((_: ServerRequest) => WebSocketConfig.default.forwardCloseFrames(true))
    )

I’m wondering if this shouldn’t be the default configuration. But either way, that’s how you can fix the problem with the current version.

1 Like

(note that you’ll also need the .decodeCloseRequests(true) on the web socket description)

See: Change default zio-http configuration so that ws close frames are forwarded to Tapir's code by adamw · Pull Request #4242 · softwaremill/tapir · GitHub

Thank you very much for the replies. Next time I’ll make sure to provide a repo or a gist containing a minimal example. To be honest I was not really sure were the problem was since I’m pretty new to both Tapir and websockets.

Anyway thank you again.