@file:JvmName("KotlinPythonKt") package com.tokilabs.proto_socket.crosstest import com.tokilabs.proto_socket.TcpClient import com.tokilabs.proto_socket.TcpServer import com.tokilabs.proto_socket.ParserMap import com.tokilabs.proto_socket.WsClient import com.tokilabs.proto_socket.WsServer import com.tokilabs.proto_socket.addListenerTyped import com.tokilabs.proto_socket.addRequestListenerTyped import com.tokilabs.proto_socket.typeNameOf import com.tokilabs.proto_socket.packets.TestData import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import java.io.File import java.security.KeyFactory import java.security.KeyStore import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import java.security.spec.PKCS8EncodedKeySpec import java.util.Base64 import java.util.concurrent.TimeUnit import javax.net.ssl.KeyManagerFactory import javax.net.ssl.SSLContext import kotlin.system.exitProcess private const val HOST = "127.0.0.1" private const val TCP_PORT = 29394 private const val WS_PORT = 29396 private const val TLS_TCP_PORT = 29398 private const val WSS_PORT = 29400 private const val WS_PATH = "/" private const val PROCESS_TIMEOUT_MS = 20_000L private const val SERVER_OBSERVATION_MS = 200L private const val STREAM_JOIN_TIMEOUT_MS = 5_000L fun main() = runBlocking { println("INFO typeName kotlin=${typeNameOf()}") val selectedTransport = System.getenv("PROTO_SOCKET_CROSSTEST_TRANSPORT") try { if (selectedTransport == null || selectedTransport == "tcp") { runTcpSendPush() delay(150) runTcpRequests() delay(150) runTcpLegacySimpleReceive() delay(150) } if (selectedTransport == null || selectedTransport == "ws") { runWs() delay(150) } if (selectedTransport == null || selectedTransport == "tls") { runTlsTcp() delay(150) } if (selectedTransport == null || selectedTransport == "wss") { runWss() } println("PASS all kotlin-server/python-client crosstests passed") } catch (error: Throwable) { System.err.println("FAIL crosstest error=${error.message ?: error}") exitProcess(1) } } private suspend fun runTcpSendPush() = coroutineScope { val received = CompletableDeferred() val server = TcpServer(HOST, TCP_PORT) { socket -> TcpClient.fromSocket(socket, 0, 0, parserMap()) } server.onClientConnected = { client -> addListenerTyped(client.communicator) { data -> println("SERVER_RECEIVED index=${data.index} message=${data.message}") val valid = data.index == 101 && data.message == "fire from python client" received.complete(valid) if (valid) { launch { client.send( TestData.newBuilder() .setIndex(200) .setMessage("push from kotlin server") .build(), ) } } } } withServer(server::start, server::stop) { runPythonClient("tcp", TCP_PORT, "send-push", setOf("1", "2")) val ok = withTimeoutOrNull(SERVER_OBSERVATION_MS) { received.await() } ?: false check(ok) { "TCP send-push server did not receive expected data" } } } private suspend fun runTcpRequests() { val server = TcpServer(HOST, TCP_PORT) { socket -> TcpClient.fromSocket(socket, 0, 0, parserMap()) } server.onClientConnected = { client -> addRequestListenerTyped(client.communicator) { req -> TestData.newBuilder() .setIndex(req.index * 2) .setMessage("echo: ${req.message}") .build() } } withServer(server::start, server::stop) { runPythonClient("tcp", TCP_PORT, "requests", setOf("3", "4")) } } private suspend fun runTcpLegacySimpleReceive() = coroutineScope { val received = CompletableDeferred() val server = TcpServer(HOST, TCP_PORT) { socket -> TcpClient.fromSocket(socket, 0, 0, parserMap()) } server.onClientConnected = { client -> addListenerTyped(client.communicator) { data -> if (received.isActive) received.complete(data.index == 101) } } withServer(server::start, server::stop) { runPythonClient("tcp", TCP_PORT, "legacy-receive", emptySet()) val ok = withTimeoutOrNull(SERVER_OBSERVATION_MS) { received.await() } ?: false check(ok) { "TCP legacy simple-name server received unexpected data" } println("PASS scenario=tcp-legacy-simple-receive detail=server-received-legacy-alias") } } private suspend fun runWs() = coroutineScope { val server = WsServer(HOST, WS_PORT, WS_PATH) { conn -> WsClient.forServer(conn, 0, 0, parserMap()) } server.start() delay(100) try { runWsSendPush(server) delay(150) runWsRequests(server) } finally { server.stop() } } private suspend fun runWsSendPush(server: WsServer) = coroutineScope { val received = CompletableDeferred() server.onClientConnected = { client -> addListenerTyped(client.communicator) { data -> println("SERVER_RECEIVED index=${data.index} message=${data.message}") val valid = data.index == 101 && data.message == "fire from python client" received.complete(valid) if (valid) { launch { client.send( TestData.newBuilder() .setIndex(200) .setMessage("push from kotlin server") .build(), ) } } } } runPythonClient("ws", WS_PORT, "send-push", setOf("1", "2")) val ok = withTimeoutOrNull(SERVER_OBSERVATION_MS) { received.await() } ?: false check(ok) { "WS send-push server did not receive expected data" } } private suspend fun runWsRequests(server: WsServer) { server.onClientConnected = { client -> addRequestListenerTyped(client.communicator) { req -> TestData.newBuilder() .setIndex(req.index * 2) .setMessage("echo: ${req.message}") .build() } } runPythonClient("ws", WS_PORT, "requests", setOf("3", "4")) } private suspend fun runTlsTcp() { val certFile = kotlinResourceFile("server.crt") val keyFile = kotlinResourceFile("server.key") val serverCtx = buildServerSslContext(certFile.path, keyFile.path) runTlsTcpSendPush(serverCtx, certFile.path) delay(150) runTlsTcpRequests(serverCtx, certFile.path) } private suspend fun runTlsTcpSendPush(serverCtx: SSLContext, certPath: String) = coroutineScope { val received = CompletableDeferred() val server = TcpServer(HOST, TLS_TCP_PORT, serverCtx) { socket -> TcpClient.fromSocket(socket, 0, 0, parserMap()) } server.onClientConnected = { client -> addListenerTyped(client.communicator) { data -> println("SERVER_RECEIVED index=${data.index} message=${data.message}") val valid = data.index == 101 && data.message == "fire from python client" received.complete(valid) if (valid) { launch { client.send( TestData.newBuilder() .setIndex(200) .setMessage("push from kotlin server") .build(), ) } } } } withServer(server::start, server::stop) { runPythonClient("tls", TLS_TCP_PORT, "send-push", setOf("1", "2"), certPath) val ok = withTimeoutOrNull(SERVER_OBSERVATION_MS) { received.await() } ?: false check(ok) { "TLS TCP send-push server did not receive expected data" } } } private suspend fun runTlsTcpRequests(serverCtx: SSLContext, certPath: String) { val server = TcpServer(HOST, TLS_TCP_PORT, serverCtx) { socket -> TcpClient.fromSocket(socket, 0, 0, parserMap()) } server.onClientConnected = { client -> addRequestListenerTyped(client.communicator) { req -> TestData.newBuilder() .setIndex(req.index * 2) .setMessage("echo: ${req.message}") .build() } } withServer(server::start, server::stop) { runPythonClient("tls", TLS_TCP_PORT, "requests", setOf("3", "4"), certPath) } } private suspend fun runWss() = coroutineScope { val certFile = kotlinResourceFile("server.crt") val keyFile = kotlinResourceFile("server.key") val serverCtx = buildServerSslContext(certFile.path, keyFile.path) val server = WsServer(HOST, WSS_PORT, WS_PATH, sslContext = serverCtx) { conn -> WsClient.forServer(conn, 0, 0, parserMap()) } server.start() delay(100) try { runWssSendPush(server, certFile.path) delay(150) runWssRequests(server, certFile.path) } finally { server.stop() } } private suspend fun runWssSendPush(server: WsServer, certPath: String) = coroutineScope { val received = CompletableDeferred() server.onClientConnected = { client -> addListenerTyped(client.communicator) { data -> println("SERVER_RECEIVED index=${data.index} message=${data.message}") val valid = data.index == 101 && data.message == "fire from python client" received.complete(valid) if (valid) { launch { client.send( TestData.newBuilder() .setIndex(200) .setMessage("push from kotlin server") .build(), ) } } } } runPythonClient("wss", WSS_PORT, "send-push", setOf("1", "2"), certPath) val ok = withTimeoutOrNull(SERVER_OBSERVATION_MS) { received.await() } ?: false check(ok) { "WSS send-push server did not receive expected data" } } private suspend fun runWssRequests(server: WsServer, certPath: String) { server.onClientConnected = { client -> addRequestListenerTyped(client.communicator) { req -> TestData.newBuilder() .setIndex(req.index * 2) .setMessage("echo: ${req.message}") .build() } } runPythonClient("wss", WSS_PORT, "requests", setOf("3", "4"), certPath) } private suspend fun withServer( start: () -> Unit, stop: () -> Unit, body: suspend () -> Unit, ) { start() delay(100) try { body() } finally { stop() } } private fun destroyProcessTree(process: Process) { process.toHandle().descendants().forEach { it.destroyForcibly() } process.destroyForcibly() } private suspend fun runPythonClient( mode: String, port: Int, phase: String, expectedScenarios: Set, certPath: String? = null, ) = withContext(Dispatchers.IO) { val args = mutableListOf( "python3", "crosstest/kotlin_python_client.py", "--mode=$mode", "--port=$port", "--phase=$phase", ) if (certPath != null) { args += "--cert=$certPath" } val process = ProcessBuilder(args) .directory(pythonDir()) .redirectErrorStream(false) .start() val stdoutLines = mutableListOf() val stdoutThread = Thread { process.inputStream.bufferedReader().forEachLine { line -> println(line) if (line.startsWith("PASS ") || line.startsWith("FAIL ")) { stdoutLines.add(line) } } } val stderrThread = Thread { process.errorStream.bufferedReader().forEachLine { line -> System.err.println(line) } } stdoutThread.start() stderrThread.start() val finished = process.waitFor(PROCESS_TIMEOUT_MS, TimeUnit.MILLISECONDS) if (!finished) { destroyProcessTree(process) } stdoutThread.join(STREAM_JOIN_TIMEOUT_MS) stderrThread.join(STREAM_JOIN_TIMEOUT_MS) val label = "python-client $mode/$phase" check(!stdoutThread.isAlive && !stderrThread.isAlive) { "$label stream readers did not finish" } validateResultLines( label, if (finished) process.exitValue() else -1, stdoutLines, expectedScenarios, ) } private fun validateResultLines( label: String, exitCode: Int, lines: List, expectedScenarios: Set, ) { val failed = lines.filter { it.startsWith("FAIL ") } val passed = lines .filter { it.startsWith("PASS ") } .mapNotNull { Regex("""scenario=([^ ]+)""").find(it)?.groupValues?.get(1) } .toSet() val missing = expectedScenarios - passed check(exitCode == 0 && failed.isEmpty() && missing.isEmpty()) { "$label failed exitCode=$exitCode failed=$failed missing=$missing" } } private fun parserMap(): ParserMap = mapOf(typeNameOf() to { TestData.parseFrom(it) }) private fun buildServerSslContext(certPath: String, keyPath: String): SSLContext { val certFactory = CertificateFactory.getInstance("X.509") val cert = File(certPath).inputStream().use { certFactory.generateCertificate(it) as X509Certificate } val keyPem = File(keyPath).readText() .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replace("\\s+".toRegex(), "") val privateKey = KeyFactory.getInstance("RSA") .generatePrivate(PKCS8EncodedKeySpec(Base64.getDecoder().decode(keyPem))) val keyStore = KeyStore.getInstance("PKCS12") keyStore.load(null, null) keyStore.setKeyEntry("toki", privateKey, CharArray(0), arrayOf(cert)) val keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) keyManagerFactory.init(keyStore, CharArray(0)) val ctx = SSLContext.getInstance("TLS") ctx.init(keyManagerFactory.keyManagers, null, null) return ctx } private fun kotlinResourceFile(name: String): File { var dir = File(System.getProperty("user.dir")).absoluteFile while (dir.parentFile != null) { val direct = File(dir, "src/test/resources/$name").canonicalFile if (direct.isFile) return direct val nested = File(dir, "kotlin/src/test/resources/$name").canonicalFile if (nested.isFile) return nested dir = dir.parentFile } error("cannot resolve kotlin test resource $name") } private fun pythonDir(): File { var dir = File(System.getProperty("user.dir")).absoluteFile while (dir.parentFile != null) { val candidate = File(dir, "../python").canonicalFile if (File(candidate, "pyproject.toml").isFile) return candidate val direct = File(dir, "python").canonicalFile if (File(direct, "pyproject.toml").isFile) return direct dir = dir.parentFile } error("cannot resolve python directory") }