Coverage Summary for Class: LoggingRawWebSocketSession (dev.skymansandy.wiretap.plugin.ws)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| LoggingRawWebSocketSession |
66.7%
(8/12)
|
50%
(7/14)
|
90.6%
(29/32)
|
71%
(228/321)
|
| LoggingRawWebSocketSession$logFrame$1 |
100%
(1/1)
|
88.9%
(16/18)
|
96%
(24/25)
|
99.5%
(191/192)
|
| LoggingRawWebSocketSession$reportClosure$1 |
100%
(1/1)
|
50%
(5/10)
|
100%
(18/18)
|
89.4%
(110/123)
|
| Total |
71.4%
(10/14)
|
66.7%
(28/42)
|
94.7%
(71/75)
|
83.2%
(529/636)
|
/*
* Copyright (c) 2026 skymansandy. All rights reserved.
*/
package dev.skymansandy.wiretap.plugin.ws
import co.touchlab.stately.concurrency.AtomicBoolean
import dev.skymansandy.wiretap.domain.model.SocketConnection
import dev.skymansandy.wiretap.domain.model.SocketContentType
import dev.skymansandy.wiretap.domain.model.SocketMessage
import dev.skymansandy.wiretap.domain.model.SocketMessageType
import dev.skymansandy.wiretap.domain.model.SocketStatus
import dev.skymansandy.wiretap.domain.model.config.ws.BinaryFrameDecoding
import dev.skymansandy.wiretap.domain.orchestrator.SocketLogManager
import dev.skymansandy.wiretap.helper.markers.InternalWiretapApi
import dev.skymansandy.wiretap.helper.util.currentTimeMillis
import dev.skymansandy.wiretap.helper.ws.decodeBinaryFrame
import dev.skymansandy.wiretap.plugin.ws.util.LoggingReceiveChannel
import dev.skymansandy.wiretap.plugin.ws.util.LoggingSendChannel
import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketExtension
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.readBytes
import io.ktor.websocket.readText
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.launch
/**
* Wraps a raw [WebSocketSession] from the engine to log all frames
* passing through, **before** Ktor's `WebSockets` plugin wraps it
* in `DefaultWebSocketSession` / `DefaultClientWebSocketSession`.
*
* This enables transparent interception of libraries that manage
* their own WebSocket sessions internally (e.g. SignalRKore),
* without requiring an explicit `wiretapped()` call.
*/
@OptIn(InternalWiretapApi::class)
internal class LoggingRawWebSocketSession(
private val delegate: WebSocketSession,
private val socketId: Long,
private val url: String,
private val socketLogManager: SocketLogManager,
private val binaryDecoding: BinaryFrameDecoding = BinaryFrameDecoding.Auto,
) : WebSocketSession {
override val coroutineContext = delegate.coroutineContext
override var masking: Boolean
get() = runCatching { delegate.masking }.getOrDefault(true)
set(value) { runCatching { delegate.masking = value } }
override var maxFrameSize: Long
get() = runCatching { delegate.maxFrameSize }.getOrDefault(Long.MAX_VALUE)
set(value) { runCatching { delegate.maxFrameSize = value } }
override val extensions: List<WebSocketExtension<*>>
get() = runCatching { delegate.extensions }.getOrDefault(emptyList())
private val logScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val closureReported = AtomicBoolean(false)
private fun reportClosure(cause: Throwable?) {
if (!closureReported.compareAndSet(expected = false, new = true)) return
logScope.launch {
when {
cause != null && cause !is CancellationException -> {
socketLogManager.updateSocket(
SocketConnection(
id = socketId,
url = url,
status = SocketStatus.Failed,
failureMessage = cause.message ?: cause::class.simpleName ?: "Unknown error",
closedAt = currentTimeMillis(),
timestamp = currentTimeMillis(),
),
)
}
else -> {
socketLogManager.updateSocket(
SocketConnection(
id = socketId,
url = url,
status = SocketStatus.Closed,
closeReason = if (cause is CancellationException) "Cancelled" else null,
closedAt = currentTimeMillis(),
timestamp = currentTimeMillis(),
),
)
}
}
}
}
init {
delegate.coroutineContext[Job]?.invokeOnCompletion { cause ->
// Guard: if the outgoing channel is still open, the raw engine session's
// Job completed due to Ktor wrapping it into DefaultClientWebSocketSession,
// not because the actual WebSocket connection closed.
// FIXME: need to test this more.
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
if (cause == null && !delegate.outgoing.isClosedForSend) return@invokeOnCompletion
reportClosure(cause)
}
}
/**
* Wraps the delegate's incoming channel without launching any coroutines.
* This avoids scope/lifecycle issues — the channel stays open as long as
* the delegate's channel does, regardless of coroutine Job completion.
*/
override val incoming: ReceiveChannel<Frame> = LoggingReceiveChannel(
delegate = delegate.incoming,
logAction = { frame -> logFrame(frame, SocketMessageType.Received) },
onChannelClosed = { cause -> reportClosure(cause) },
)
override val outgoing: SendChannel<Frame> = LoggingSendChannel(
delegate = delegate.outgoing,
logAction = { frame -> logFrame(frame, SocketMessageType.Sent) },
)
override suspend fun send(frame: Frame) {
logFrame(frame, SocketMessageType.Sent)
delegate.send(frame)
}
override suspend fun flush() {
delegate.flush()
}
@Deprecated("Use cancel() instead.", replaceWith = ReplaceWith("cancel()"), level = DeprecationLevel.ERROR)
override fun terminate() {
@Suppress("DEPRECATION_ERROR")
delegate.terminate()
}
private fun logFrame(frame: Frame, direction: SocketMessageType) {
logScope.launch {
val (contentType, content, byteCount) = when (frame) {
is Frame.Text -> {
val text = frame.readText()
Triple(SocketContentType.Text, text, text.encodeToByteArray().size.toLong())
}
is Frame.Binary -> {
val bytes = frame.readBytes()
Triple(SocketContentType.Binary, decodeBinaryFrame(bytes, binaryDecoding), bytes.size.toLong())
}
is Frame.Ping -> Triple(SocketContentType.Ping, "", frame.data.size.toLong())
is Frame.Pong -> Triple(SocketContentType.Pong, "", frame.data.size.toLong())
is Frame.Close -> {
val bytes = frame.data
val closeContent = if (bytes.size >= 2) {
val closeCode = (bytes[0].toInt() and 0xFF shl 8) or (bytes[1].toInt() and 0xFF)
val closeReason = if (bytes.size > 2) bytes.decodeToString(2, bytes.size) else ""
if (closeReason.isNotEmpty()) "$closeCode $closeReason" else "$closeCode"
} else {
""
}
Triple(SocketContentType.Close, closeContent, bytes.size.toLong())
}
else -> return@launch
}
socketLogManager.logSocketMsg(
SocketMessage(
socketId = socketId,
direction = direction,
contentType = contentType,
content = content,
byteCount = byteCount,
timestamp = currentTimeMillis(),
),
)
}
}
}