fix(relay): answer a relay's WebSocket CLOSE frame so OkHttp can finish the handshake

OkHttp fires onClosed only once both peers have sent a CLOSE frame, and
sending ours is the application's job (WebSocketListener KDoc; RealWebSocket
emits onClosed solely from the writer once our Close is dequeued with the
peer's code already set; the bundled WebSocketEcho recipe answers onClosing
with close(1000, null)). BasicOkHttpWebSocket never implemented onClosing,
so a relay-initiated close left the socket half-closed: no onClosed, no
onFailure, send() still accepted and silently discarded, and a later
cancel() silent as well because no reader was left to fail. The relay
client kept believing it was connected, with its REQs live, until the 120s
ping path finally failed up to two intervals later.

Answer onClosing with close(1000, null). Verified against OkHttp 5.5.0:
onClosed then fires at once whether the relay still holds the TCP session
or has already dropped it, and the existing onClosed path in
BasicRelayClient marks the connection closed and lets the pool reconnect
under its normal backoff. Always 1000 rather than echoing the relay's code,
since close() validates the code it writes and relays may send reserved
ones.

The test drives the wrapper against a minimal RFC 6455 server on a loopback
ServerSocket (no new dependency): the relay sends CLOSE, the client must
answer with its own CLOSE frame and report onClosed with the relay's code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
This commit is contained in:
Claude
2026-09-12 22:52:37 +00:00
parent 1093b3ce8a
commit b8135e76c4
2 changed files with 238 additions and 0 deletions
@@ -96,6 +96,28 @@ class BasicOkHttpWebSocket(
incomingMessages.trySendBlocking(text)
}
override fun onClosing(
webSocket: OkHttpWebSocket,
code: Int,
reason: String,
) {
// The relay sent a CLOSE frame. OkHttp's contract (WebSocketListener KDoc,
// RealWebSocket, and its own WebSocketEcho recipe) is that onClosed fires
// only once BOTH peers have sent a close, and sending ours is the
// application's job. Left unanswered, the socket sits half-closed: no
// onClosed, no onFailure, send() still accepted and silently discarded, and
// a later cancel() is silent too -- so the relay client kept believing it
// was connected, with its REQs live, until OkHttp's 120s ping path finally
// failed up to two intervals later. Answering completes the handshake and
// OkHttp reports onClosed at once, whether or not the relay still holds the
// TCP session open.
//
// Always 1000 rather than echoing `code`: close() validates the code it is
// asked to write and throws on the reserved ones (1005, 1006, 1015), and a
// relay may send anything.
webSocket.close(1000, null)
}
override fun onClosed(
webSocket: OkHttpWebSocket,
code: Int,
@@ -0,0 +1,216 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import okhttp3.OkHttpClient
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.InputStream
import java.io.OutputStream
import java.net.ServerSocket
import java.net.Socket
import java.security.MessageDigest
import java.util.Base64
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import kotlin.concurrent.thread
/**
* A relay-initiated close must be answered, or OkHttp never finishes the handshake.
*
* OkHttp fires `onClosed` only once BOTH peers have sent a CLOSE frame, and sending ours is the
* application's job (its `WebSocketEcho` recipe answers `onClosing` with `close(1000, null)`).
* Before [BasicOkHttpWebSocket] did that, a relay's CLOSE frame left the socket half-closed: no
* `onClosed`, no `onFailure`, `send()` still accepted and discarded, and a later `cancel()` silent
* too. The relay client kept believing it was connected, with its REQs live, until the ping path
* failed up to two ping intervals later.
*
* Driven against a minimal RFC 6455 server on a loopback [ServerSocket] rather than a mock
* server library, so the test needs no new dependency and controls the exact frames on the wire.
*/
class BasicOkHttpWebSocketCloseHandshakeTest {
/** Handshakes one client, sends it a CLOSE frame on demand, and records the frames it sends back. */
private class TinyRelay : AutoCloseable {
private val server = ServerSocket(0)
val url = NormalizedRelayUrl("ws://127.0.0.1:${server.localPort}/")
private val handshaken = CountDownLatch(1)
val clientCloseFrame = CountDownLatch(1)
val clientCloseCode = AtomicInteger(-1)
private var socket: Socket? = null
private var out: OutputStream? = null
private val thread =
thread(isDaemon = true, name = "tiny-relay") {
runCatching {
val s = server.accept()
socket = s
val input = s.getInputStream()
val output = s.getOutputStream()
out = output
handshake(input, output)
handshaken.countDown()
readFrames(input)
}
}
private fun handshake(
input: InputStream,
output: OutputStream,
) {
var key: String? = null
val line = StringBuilder()
while (true) {
val c = input.read()
check(c != -1) { "EOF during handshake" }
if (c == '\n'.code) {
val l = line.toString().trim()
if (l.isEmpty()) break
if (l.lowercase().startsWith("sec-websocket-key:")) key = l.substring(18).trim()
line.setLength(0)
} else if (c != '\r'.code) {
line.append(c.toChar())
}
}
val accept =
Base64.getEncoder().encodeToString(
MessageDigest.getInstance("SHA-1").digest((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").toByteArray()),
)
output.write(
(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Accept: $accept\r\n\r\n"
).toByteArray(Charsets.ISO_8859_1),
)
output.flush()
}
/** Client frames are masked; decode enough to spot a CLOSE and read its status code. */
private fun readFrames(input: InputStream) {
while (true) {
val b0 = input.read()
if (b0 == -1) return
val b1 = input.read()
if (b1 == -1) return
val opcode = b0 and 0x0F
var len = b1 and 0x7F
if (len == 126) {
len = (input.read() shl 8) or input.read()
} else if (len == 127) {
len = 0
repeat(8) { len = (len shl 8) or input.read() }
}
val masked = (b1 and 0x80) != 0
val mask = if (masked) ByteArray(4) { input.read().toByte() } else ByteArray(4)
val payload = ByteArray(len) { i -> (input.read() xor mask[i % 4].toInt()).toByte() }
if (opcode == 0x8) {
if (len >= 2) {
clientCloseCode.set(((payload[0].toInt() and 0xFF) shl 8) or (payload[1].toInt() and 0xFF))
}
clientCloseFrame.countDown()
}
}
}
fun awaitClient() = handshaken.await(5, TimeUnit.SECONDS)
/** Server-initiated CLOSE, status 1000, unmasked as servers send it. The TCP session stays open. */
fun sendClose() {
val output = checkNotNull(out) { "no client yet" }
output.write(byteArrayOf(0x88.toByte(), 0x02, 0x03, 0xE8.toByte()))
output.flush()
}
override fun close() {
runCatching { socket?.close() }
runCatching { server.close() }
thread.join(2_000)
}
}
private class Recorder : WebSocketListener {
val opened = CountDownLatch(1)
val closed = CountDownLatch(1)
val closedCode = AtomicInteger(-1)
val failure = AtomicReference<Throwable?>(null)
override fun onOpen(
pingMillis: Int,
compression: Boolean,
) = opened.countDown()
override suspend fun onMessage(text: String) {}
override fun onClosed(
code: Int,
reason: String,
) {
closedCode.set(code)
closed.countDown()
}
override fun onFailure(
t: Throwable,
code: Int?,
response: String?,
) {
failure.set(t)
}
}
@Test
fun `a relay initiated close is answered and reported as closed`() {
TinyRelay().use { relay ->
val recorder = Recorder()
val client = OkHttpClient()
val socket = BasicOkHttpWebSocket(relay.url, { client }, recorder)
socket.connect()
assertTrue("relay never saw the client", relay.awaitClient())
assertTrue("no onOpen", recorder.opened.await(5, TimeUnit.SECONDS))
relay.sendClose()
// The half of the handshake that is ours to send.
assertTrue("client never answered the relay's CLOSE frame", relay.clientCloseFrame.await(5, TimeUnit.SECONDS))
assertEquals(1000, relay.clientCloseCode.get())
// And the terminal callback the relay client's bookkeeping depends on.
assertTrue("onClosed never fired", recorder.closed.await(5, TimeUnit.SECONDS))
assertEquals("the relay's status code is what gets reported", 1000, recorder.closedCode.get())
assertNull("a clean handshake is not a failure", recorder.failure.get())
// The usual teardown afterwards must stay a harmless no-op.
socket.disconnect()
client.dispatcher.executorService.shutdown()
}
}
}