From c3bfb35a72275da03a3e668d3958a4403d887965 Mon Sep 17 00:00:00 2001 From: toki Date: Mon, 27 Apr 2026 01:05:21 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B8=B0=EB=8A=A5:=20kotlin=20server=20crosste?= =?UTF-8?q?st=EC=97=90=20WSS=20phase=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WsServer TLS 지원 완료 후 crosstest에 누락됐던 WSS phase를 4개 orchestrator에 추가. TypeScript는 handshake 안정성을 위해 phase별 서버 인스턴스를 분리했다. WsServer init 강화(setReuseAddr, startError 전파), TypeScript closeWebSocket 안정화 포함. --- .../kotlin_wss_crosstest/code_review_0.log | 231 ++++++++++++ agent-task/kotlin_wss_crosstest/complete.log | 26 ++ agent-task/kotlin_wss_crosstest/plan_0.log | 352 ++++++++++++++++++ kotlin/crosstest/kotlin_dart.kt | 57 +++ kotlin/crosstest/kotlin_go.kt | 57 +++ kotlin/crosstest/kotlin_python.kt | 57 +++ kotlin/crosstest/kotlin_typescript.kt | 68 ++++ .../com/tokilabs/toki_socket/WsServer.kt | 45 ++- typescript/src/ws_client.ts | 41 +- 9 files changed, 926 insertions(+), 8 deletions(-) create mode 100644 agent-task/kotlin_wss_crosstest/code_review_0.log create mode 100644 agent-task/kotlin_wss_crosstest/complete.log create mode 100644 agent-task/kotlin_wss_crosstest/plan_0.log diff --git a/agent-task/kotlin_wss_crosstest/code_review_0.log b/agent-task/kotlin_wss_crosstest/code_review_0.log new file mode 100644 index 0000000..7da7984 --- /dev/null +++ b/agent-task/kotlin_wss_crosstest/code_review_0.log @@ -0,0 +1,231 @@ + + +# Code Review Reference - KOTLIN_WSS_CROSSTEST + +## 개요 + +date=2026-04-27 +task=kotlin_wss_crosstest, plan=0, tag=KOTLIN_WSS_CROSSTEST + +## 이 파일을 읽는 리뷰 에이전트에게 + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료 후 반드시 아래 순서로 아카이브하세요. + +1. `CODE_REVIEW.md` → `code_review_0.log` (N = 기존 code_review_*.log 수) +2. `PLAN.md` → `plan_0.log` (M = 기존 plan_*.log 수) +3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [KOTLIN_WSS_CROSSTEST-1] kotlin_dart.kt — WSS phase 추가 | [x] | +| [KOTLIN_WSS_CROSSTEST-2] kotlin_go.kt — WSS phase 추가 | [x] | +| [KOTLIN_WSS_CROSSTEST-3] kotlin_python.kt — WSS phase 추가 | [x] | +| [KOTLIN_WSS_CROSSTEST-4] kotlin_typescript.kt — WSS phase 추가 | [x] | + +## 계획 대비 변경 사항 + +- 계획은 `WsServer(..., sslContext = serverCtx)`가 이미 존재한다고 전제했으나, 실제 `WsServer`에는 TLS 인자가 없었다. WSS crosstest 컴파일을 위해 `kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsServer.kt`에 `SSLContext?` 인자와 `DefaultSSLWebSocketServerFactory` 설정을 추가했다. +- `WsServer.start()`는 Java-WebSocket 내부 시작 실패가 `onError(null, ex)`로 전달되고 호출자에는 숨겨질 수 있어, 실제 `onStart` 또는 시작 오류를 기다리도록 보강했다. 반복 검증 중 fixed port 재사용 실패를 줄이기 위해 `setReuseAddr(true)`도 추가했다. +- 기존 `TlsWsTest`가 `server.port()`를 사용하고 있어 `WsServer.port()` 호환 메서드를 추가했다. +- TypeScript WSS는 단일 Java-WebSocket TLS 서버에서 `npx tsx` 클라이언트가 연속 접속할 때 다음 handshake가 간헐적으로 멈췄다. `kotlin_typescript.kt`의 WSS phase만 TLS TCP처럼 `send-push`와 `requests`에 서버 인스턴스를 분리했다. +- TypeScript `WsClient.close()`가 `ws.close()` 호출 후 close 완료를 기다리지 않아 WSS 재접속 안정성이 떨어졌다. `typescript/src/ws_client.ts`에서 `close/error` 이벤트 또는 1초 timeout까지 기다리도록 보강했다. + +## 주요 설계 결정 + +- Dart/Go/Python orchestrator는 계획대로 단일 WSS 서버 인스턴스를 `send-push`와 `requests`에 재사용했다. +- TypeScript orchestrator만 클라이언트 close/handshake 특성 때문에 WSS 서버를 phase별로 분리했다. 외부 동작은 동일하게 `mode=wss`, 동일 포트 `29800`, 동일 시나리오 set을 검증한다. +- `WsServer` TLS 지원은 기존 WS 호출과 호환되도록 nullable 기본 인자(`sslContext: SSLContext? = null`)로 추가했다. + +## 리뷰어를 위한 체크포인트 + +- **포트 충돌 없음**: 각 파일의 `WSS_PORT`가 같은 파일 내 기존 TCP/WS/TLS_TCP 포트와 겹치지 않는지 확인 (dart=29496, go=29396, python=29400, typescript=29800). +- **WsServer sslContext 전달**: `WsServer(..., sslContext = serverCtx)` 형태로 생성하는지 확인. 기존 `runWs()`의 WsServer 생성과 비교. +- **서버 인스턴스 수**: Dart/Go/Python은 `runWss()` 안에서 서버를 한 번 start하고 `runWssSendPush` → `runWssRequests` 순으로 재사용하는지 확인. TypeScript는 계획 대비 변경 사항에 기록한 이유로 WSS phase별 서버를 사용한다. +- **client runner 인수**: `runDartClient("wss", WSS_PORT, ..., certPath)` 처럼 mode=`"wss"` + certPath를 전달하는지 확인. +- **PASS 메시지 순서**: `main()`에서 `runWss()`가 `runTlsTcp()` 다음, PASS 출력 직전에 위치하는지 확인. +- **4개 파일 일관성**: `runWssSendPush` / `runWssRequests`의 검증 내용과 client runner 인수가 4개 파일에서 동일한 패턴을 따르는지 확인. TypeScript의 서버 lifetime만 예외다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +### 중간 검증 (컴파일) + +``` +$ cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle ./gradlew compileCrosstestKotlin +> Task :compileCrosstestKotlin + +BUILD SUCCESSFUL in 2s +9 actionable tasks: 1 executed, 8 up-to-date +``` + +### 최종 검증 + +``` +$ cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinDartKt +INFO typeName kotlin=TestData +INFO typeName dart=TestData +PASS scenario=1 detail=fire-and-forget sent +SERVER_RECEIVED index=101 message=fire from dart client +PASS scenario=2 detail=received push from kotlin server +INFO typeName dart=TestData +PASS scenario=3 detail=single request response matched +PASS scenario=4 detail=concurrent request responses matched +SLF4J: No SLF4J providers were found. +SLF4J: Defaulting to no-operation (NOP) logger implementation +SLF4J: See https://www.slf4j.org/codes.html#noProviders for further details. +INFO typeName dart=TestData +PASS scenario=1 detail=fire-and-forget sent +SERVER_RECEIVED index=101 message=fire from dart client +PASS scenario=2 detail=received push from kotlin server +INFO typeName dart=TestData +PASS scenario=3 detail=single request response matched +PASS scenario=4 detail=concurrent request responses matched +INFO typeName dart=TestData +PASS scenario=1 detail=fire-and-forget sent +SERVER_RECEIVED index=101 message=fire from dart client +PASS scenario=2 detail=received push from kotlin server +INFO typeName dart=TestData +PASS scenario=3 detail=single request response matched +PASS scenario=4 detail=concurrent request responses matched +INFO typeName dart=TestData +PASS scenario=1 detail=fire-and-forget sent +SERVER_RECEIVED index=101 message=fire from dart client +PASS scenario=2 detail=received push from kotlin server +INFO typeName dart=TestData +PASS scenario=3 detail=single request response matched +PASS scenario=4 detail=concurrent request responses matched +PASS all kotlin-server/dart-client crosstests passed + +BUILD SUCCESSFUL in 7s + +$ cd kotlin && env PATH=/config/go-sdk/go/bin:$PATH JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle GOCACHE=/tmp/go-build GOMODCACHE=/tmp/go-mod ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinGoKt +INFO typeName kotlin=TestData +INFO typeName go=TestData +PASS scenario=1 detail=fire-and-forget sent +SERVER_RECEIVED index=101 message=fire from go client +PASS scenario=2 detail=received push from kotlin server +INFO typeName go=TestData +PASS scenario=3 detail=single request response matched +PASS scenario=4 detail=concurrent request responses matched +SLF4J: No SLF4J providers were found. +SLF4J: Defaulting to no-operation (NOP) logger implementation +SLF4J: See https://www.slf4j.org/codes.html#noProviders for further details. +INFO typeName go=TestData +PASS scenario=1 detail=fire-and-forget sent +SERVER_RECEIVED index=101 message=fire from go client +PASS scenario=2 detail=received push from kotlin server +INFO typeName go=TestData +PASS scenario=3 detail=single request response matched +PASS scenario=4 detail=concurrent request responses matched +INFO typeName go=TestData +PASS scenario=1 detail=fire-and-forget sent +SERVER_RECEIVED index=101 message=fire from go client +PASS scenario=2 detail=received push from kotlin server +INFO typeName go=TestData +PASS scenario=3 detail=single request response matched +PASS scenario=4 detail=concurrent request responses matched +INFO typeName go=TestData +PASS scenario=1 detail=fire-and-forget sent +SERVER_RECEIVED index=101 message=fire from go client +PASS scenario=2 detail=received push from kotlin server +INFO typeName go=TestData +PASS scenario=3 detail=single request response matched +PASS scenario=4 detail=concurrent request responses matched +PASS all kotlin-server/go-client crosstests passed + +BUILD SUCCESSFUL in 6s + +$ cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinPythonKt +INFO typeName kotlin=TestData +SERVER_RECEIVED index=101 message=fire from python client +INFO typeName python=TestData +PASS scenario=1 detail=fire-and-forget sent +PASS scenario=2 detail=received push from kotlin server +INFO typeName python=TestData +PASS scenario=3 detail=single request response matched +PASS scenario=4 detail=concurrent request responses matched +SLF4J: No SLF4J providers were found. +SLF4J: Defaulting to no-operation (NOP) logger implementation +SLF4J: See https://www.slf4j.org/codes.html#noProviders for further details. +SERVER_RECEIVED index=101 message=fire from python client +INFO typeName python=TestData +PASS scenario=1 detail=fire-and-forget sent +PASS scenario=2 detail=received push from kotlin server +INFO typeName python=TestData +PASS scenario=3 detail=single request response matched +PASS scenario=4 detail=concurrent request responses matched +SERVER_RECEIVED index=101 message=fire from python client +INFO typeName python=TestData +PASS scenario=1 detail=fire-and-forget sent +PASS scenario=2 detail=received push from kotlin server +INFO typeName python=TestData +PASS scenario=3 detail=single request response matched +PASS scenario=4 detail=concurrent request responses matched +SERVER_RECEIVED index=101 message=fire from python client +INFO typeName python=TestData +PASS scenario=1 detail=fire-and-forget sent +PASS scenario=2 detail=received push from kotlin server +INFO typeName python=TestData +PASS scenario=3 detail=single request response matched +PASS scenario=4 detail=concurrent request responses matched +PASS all kotlin-server/python-client crosstests passed + +BUILD SUCCESSFUL in 4s + +$ cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinTypescriptKt +INFO typeName kotlin=TestData +INFO typeName ts=TestData +PASS scenario=1 detail=fire-and-forget sent +SERVER_RECEIVED index=101 message=fire from typescript client +PASS scenario=2 detail=received push from kotlin server +INFO typeName ts=TestData +PASS scenario=3 detail=single request response matched +PASS scenario=4 detail=concurrent request responses matched +SLF4J: No SLF4J providers were found. +SLF4J: Defaulting to no-operation (NOP) logger implementation +SLF4J: See https://www.slf4j.org/codes.html#noProviders for further details. +INFO typeName ts=TestData +PASS scenario=1 detail=fire-and-forget sent +SERVER_RECEIVED index=101 message=fire from typescript client +PASS scenario=2 detail=received push from kotlin server +INFO typeName ts=TestData +PASS scenario=3 detail=single request response matched +PASS scenario=4 detail=concurrent request responses matched +INFO typeName ts=TestData +PASS scenario=1 detail=fire-and-forget sent +SERVER_RECEIVED index=101 message=fire from typescript client +PASS scenario=2 detail=received push from kotlin server +INFO typeName ts=TestData +PASS scenario=3 detail=single request response matched +PASS scenario=4 detail=concurrent request responses matched +INFO typeName ts=TestData +PASS scenario=1 detail=fire-and-forget sent +SERVER_RECEIVED index=101 message=fire from typescript client +PASS scenario=2 detail=received push from kotlin server +INFO typeName ts=TestData +PASS scenario=3 detail=single request response matched +PASS scenario=4 detail=concurrent request responses matched +PASS all kotlin-server/typescript-client crosstests passed + +BUILD SUCCESSFUL in 7s +``` + +### 추가 검증 + +``` +$ cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle ./gradlew test --tests com.tokilabs.toki_socket.TlsWsTest +> Task :test + +BUILD SUCCESSFUL in 9s +11 actionable tasks: 4 executed, 7 up-to-date + +$ cd typescript && npm run check +> toki-socket@1.0.5 check +> tsc --noEmit +``` diff --git a/agent-task/kotlin_wss_crosstest/complete.log b/agent-task/kotlin_wss_crosstest/complete.log new file mode 100644 index 0000000..027d4b6 --- /dev/null +++ b/agent-task/kotlin_wss_crosstest/complete.log @@ -0,0 +1,26 @@ +task=kotlin_wss_crosstest plan=0 tag=KOTLIN_WSS_CROSSTEST +date=2026-04-27 +result=PASS + +## 요약 + +Kotlin server orchestrator 4개에 WSS phase 추가. 1 plan 루프. + +## 루프 이력 + +| plan | code_review | 판정 | +|------|-------------|------| +| plan_0.log | code_review_0.log | PASS | + +## 최종 리뷰 요약 + +- [KOTLIN_WSS_CROSSTEST-1] kotlin_dart.kt: WSS_PORT=29496, runWss()/runWssSendPush()/runWssRequests() 추가 +- [KOTLIN_WSS_CROSSTEST-2] kotlin_go.kt: WSS_PORT=29396, 동일 패턴 +- [KOTLIN_WSS_CROSSTEST-3] kotlin_python.kt: WSS_PORT=29400, 동일 패턴 +- [KOTLIN_WSS_CROSSTEST-4] kotlin_typescript.kt: WSS_PORT=29800, withWssServer() helper로 phase별 서버 분리 +- WsServer.kt: setWebSocketFactory → init 블록 이동, setReuseAddr(true), startError 전파, compareAndSet 가드로 강화 +- TypeScript ws_client.ts: closeWebSocket에 close/error 이벤트 + 1초 timeout 대기 추가 + +## 잔여 Nit + +- code_review_0.log 계획 대비 변경 사항: "WsServer에 TLS 인자가 없었다"는 부정확. kotlin_wss_tls에서 기존 추가된 것을 리팩토링한 것이 실제 내용. diff --git a/agent-task/kotlin_wss_crosstest/plan_0.log b/agent-task/kotlin_wss_crosstest/plan_0.log new file mode 100644 index 0000000..c95dfd5 --- /dev/null +++ b/agent-task/kotlin_wss_crosstest/plan_0.log @@ -0,0 +1,352 @@ + + +# Kotlin Server WSS Crosstest 추가 + +## 이 파일을 읽는 구현 에이전트에게 + +각 항목의 체크리스트를 완료 후 `[x]`로 표시한다. +중간 검증 명령을 실제로 실행하고 출력을 `CODE_REVIEW.md`의 검증 결과 섹션에 붙여 넣는다. +계획과 다르게 구현한 부분은 반드시 `CODE_REVIEW.md`의 "계획 대비 변경 사항"에 이유와 함께 기록한다. + +## 배경 + +`kotlin_wss_tls` task에서 `WsServer`에 `sslContext` 파라미터를 추가해 WSS를 지원하게 됐다. +그러나 Kotlin server orchestrator 4개(`kotlin_dart.kt`, `kotlin_go.kt`, `kotlin_python.kt`, `kotlin_typescript.kt`)는 +TLS TCP phase만 추가됐고 WSS phase는 누락된 상태다. +4개 client helper(`dart_kotlin_client.dart`, `go_kotlin_client Main.kt`, `python_kotlin_client.py`, `typescript_kotlin_client.ts`)는 +모두 `"wss"` case가 이미 구현되어 있으므로 orchestrator쪽만 수정하면 된다. + +## 포트 배정 + +| 파일 | TCP | WS | TLS TCP | WSS (신규) | +|------|-----|----|---------|-----------| +| kotlin_dart.kt | 29490 | 29492 | 29494 | **29496** | +| kotlin_go.kt | 29390 | 29392 | 29394 | **29396** | +| kotlin_python.kt | 29394 | 29396 | 29398 | **29400** | +| kotlin_typescript.kt | 29794 | 29796 | 29798 | **29800** | + +각 WSS 포트는 해당 파일 내 기존 포트와 겹치지 않는다. 타 파일과 동일 포트가 있어도 orchestrator는 순차 실행이므로 충돌 없음. + +--- + +## [KOTLIN_WSS_CROSSTEST-1] kotlin_dart.kt — WSS phase 추가 + +### 문제 + +`kotlin/crosstest/kotlin_dart.kt`의 `main()`이 `runTlsTcp()` 호출 후 바로 PASS를 출력한다. +WSS (kotlin WsServer + dart `"wss"` client) phase가 없다. + +### 해결 방법 + +1. `WSS_PORT = 29496` 상수 추가 (line 37 다음) +2. `main()`에 `runWss()` 호출 뒤 `runTlsTcp()` 전에 `runWss()` 사이에 `runWss()` 실행 후 WSS phase를 호출한다. + 정확히는 `runTlsTcp()` 다음, PASS 출력 전에 `runWss()` 대신 별도 `runWss()` WSS 함수를 추가한다. +3. `runWss()`는 기존 WS 패턴과 동일하게 단일 `WsServer` 인스턴스를 유지하고 `sslContext = serverCtx`를 전달한다. +4. `runWssSendPush` / `runWssRequests` 2개 내부 함수 추가. + +#### Before (kotlin_dart.kt:42-57) + +```kotlin +fun main() = runBlocking { + println("INFO typeName kotlin=${typeNameOf()}") + try { + runTcpSendPush() + delay(150) + runTcpRequests() + delay(150) + runWs() + delay(150) + runTlsTcp() + println("PASS all kotlin-server/dart-client crosstests passed") + } catch (error: Throwable) { + System.err.println("FAIL crosstest error=${error.message ?: error}") + exitProcess(1) + } +} +``` + +#### After + +```kotlin +fun main() = runBlocking { + println("INFO typeName kotlin=${typeNameOf()}") + try { + runTcpSendPush() + delay(150) + runTcpRequests() + delay(150) + runWs() + delay(150) + runTlsTcp() + delay(150) + runWss() + println("PASS all kotlin-server/dart-client crosstests passed") + } catch (error: Throwable) { + System.err.println("FAIL crosstest error=${error.message ?: error}") + exitProcess(1) + } +} +``` + +#### Before (kotlin_dart.kt:37) + +```kotlin +private const val TLS_TCP_PORT = 29494 +``` + +#### After + +```kotlin +private const val TLS_TCP_PORT = 29494 +private const val WSS_PORT = 29496 +``` + +#### 추가할 함수 (runTlsTcp() 이후에 삽입) + +```kotlin +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 dart client" + received.complete(valid) + if (valid) { + launch { + client.send( + TestData.newBuilder() + .setIndex(200) + .setMessage("push from kotlin server") + .build(), + ) + } + } + } + } + runDartClient("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() + } + } + runDartClient("wss", WSS_PORT, "requests", setOf("3", "4"), certPath) +} +``` + +### 수정 파일 및 체크리스트 + +- [x] `kotlin/crosstest/kotlin_dart.kt` + - [x] `WSS_PORT = 29496` 상수 추가 + - [x] `main()`에 `delay(150)` + `runWss()` 추가 (runTlsTcp() 뒤) + - [x] `runWss()`, `runWssSendPush()`, `runWssRequests()` 함수 추가 + +### 테스트 작성 + +신규 파일 불필요. orchestrator 자체가 e2e 검증이다. + +### 중간 검증 + +```bash +$ cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle ./gradlew compileCrosstestKotlin +# BUILD SUCCESSFUL +``` + +--- + +## [KOTLIN_WSS_CROSSTEST-2] kotlin_go.kt — WSS phase 추가 + +### 문제 + +`kotlin/crosstest/kotlin_go.kt`에 WSS phase 없음. `go_kotlin_client` Main.kt에는 `"wss"` case 구현됨. + +### 해결 방법 + +`kotlin_dart.kt`와 동일한 패턴. 차이점: +- `WSS_PORT = 29396` +- 클라이언트 runner: `runGoClient("wss", WSS_PORT, ...)` (이미 존재하는 함수) + +#### Before (kotlin_go.kt:37) + +```kotlin +private const val TLS_TCP_PORT = 29394 +``` + +#### After + +```kotlin +private const val TLS_TCP_PORT = 29394 +private const val WSS_PORT = 29396 +``` + +`main()` 변경, `runWss()` / `runWssSendPush()` / `runWssRequests()` 추가는 kotlin_dart.kt와 동일 패턴. +`runGoClient("wss", ...)` 호출. + +### 수정 파일 및 체크리스트 + +- [x] `kotlin/crosstest/kotlin_go.kt` + - [x] `WSS_PORT = 29396` 상수 추가 + - [x] `main()`에 `delay(150)` + `runWss()` 추가 + - [x] `runWss()`, `runWssSendPush()`, `runWssRequests()` 함수 추가 (Go client 호출) + +### 테스트 작성 + +orchestrator e2e로 충분. + +### 중간 검증 + +```bash +$ cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle ./gradlew compileCrosstestKotlin +# BUILD SUCCESSFUL +``` + +--- + +## [KOTLIN_WSS_CROSSTEST-3] kotlin_python.kt — WSS phase 추가 + +### 문제 + +`kotlin/crosstest/kotlin_python.kt`에 WSS phase 없음. `python_kotlin_client.py`에는 `"wss"` case 구현됨. + +### 해결 방법 + +동일 패턴. 차이점: +- `WSS_PORT = 29400` +- 클라이언트 runner: `runPythonClient("wss", WSS_PORT, ...)` + +#### Before (kotlin_python.kt:37) + +```kotlin +private const val TLS_TCP_PORT = 29398 +``` + +#### After + +```kotlin +private const val TLS_TCP_PORT = 29398 +private const val WSS_PORT = 29400 +``` + +### 수정 파일 및 체크리스트 + +- [x] `kotlin/crosstest/kotlin_python.kt` + - [x] `WSS_PORT = 29400` 상수 추가 + - [x] `main()`에 `delay(150)` + `runWss()` 추가 + - [x] `runWss()`, `runWssSendPush()`, `runWssRequests()` 함수 추가 (Python client 호출) + +### 테스트 작성 + +orchestrator e2e로 충분. + +### 중간 검증 + +```bash +$ cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle ./gradlew compileCrosstestKotlin +# BUILD SUCCESSFUL +``` + +--- + +## [KOTLIN_WSS_CROSSTEST-4] kotlin_typescript.kt — WSS phase 추가 + +### 문제 + +`kotlin/crosstest/kotlin_typescript.kt`에 WSS phase 없음. `typescript_kotlin_client.ts`에는 `"wss"` case 구현됨. + +### 해결 방법 + +동일 패턴. 차이점: +- `WSS_PORT = 29800` +- 클라이언트 runner: `runTypescriptClient("wss", WSS_PORT, ...)` + +#### Before (kotlin_typescript.kt:37) + +```kotlin +private const val TLS_TCP_PORT = 29798 +``` + +#### After + +```kotlin +private const val TLS_TCP_PORT = 29798 +private const val WSS_PORT = 29800 +``` + +### 수정 파일 및 체크리스트 + +- [x] `kotlin/crosstest/kotlin_typescript.kt` + - [x] `WSS_PORT = 29800` 상수 추가 + - [x] `main()`에 `delay(150)` + `runWss()` 추가 + - [x] `runWss()`, `runWssSendPush()`, `runWssRequests()` 함수 추가 (TypeScript client 호출) + +### 테스트 작성 + +orchestrator e2e로 충분. + +### 중간 검증 + +```bash +$ cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle ./gradlew compileCrosstestKotlin +# BUILD SUCCESSFUL +``` + +--- + +## 의존 관계 및 구현 순서 + +1-4번은 서로 독립적이나 중간 검증을 공유하므로 4개 모두 수정 후 한 번에 컴파일 검증. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `kotlin/crosstest/kotlin_dart.kt` | KOTLIN_WSS_CROSSTEST-1 | +| `kotlin/crosstest/kotlin_go.kt` | KOTLIN_WSS_CROSSTEST-2 | +| `kotlin/crosstest/kotlin_python.kt` | KOTLIN_WSS_CROSSTEST-3 | +| `kotlin/crosstest/kotlin_typescript.kt` | KOTLIN_WSS_CROSSTEST-4 | + +## 최종 검증 + +```bash +$ cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle \ + ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinDartKt +# PASS all kotlin-server/dart-client crosstests passed + +$ cd kotlin && env PATH=/config/go-sdk/go/bin:$PATH JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 \ + GRADLE_USER_HOME=/tmp/gradle GOCACHE=/tmp/go-build GOMODCACHE=/tmp/go-mod \ + ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinGoKt +# PASS all kotlin-server/go-client crosstests passed + +$ cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle \ + ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinPythonKt +# PASS all kotlin-server/python-client crosstests passed + +$ cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle \ + ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinTypescriptKt +# PASS all kotlin-server/typescript-client crosstests passed +``` diff --git a/kotlin/crosstest/kotlin_dart.kt b/kotlin/crosstest/kotlin_dart.kt index 1481b02..5ec2507 100644 --- a/kotlin/crosstest/kotlin_dart.kt +++ b/kotlin/crosstest/kotlin_dart.kt @@ -35,6 +35,7 @@ private const val HOST = "127.0.0.1" private const val TCP_PORT = 29490 private const val WS_PORT = 29492 private const val TLS_TCP_PORT = 29494 +private const val WSS_PORT = 29496 private const val WS_PATH = "/" private const val PROCESS_TIMEOUT_MS = 20_000L private const val SERVER_OBSERVATION_MS = 200L @@ -49,6 +50,8 @@ fun main() = runBlocking { runWs() delay(150) runTlsTcp() + delay(150) + runWss() println("PASS all kotlin-server/dart-client crosstests passed") } catch (error: Throwable) { System.err.println("FAIL crosstest error=${error.message ?: error}") @@ -208,6 +211,60 @@ private suspend fun runTlsTcpRequests(serverCtx: SSLContext, certPath: String) { } } +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 dart client" + received.complete(valid) + if (valid) { + launch { + client.send( + TestData.newBuilder() + .setIndex(200) + .setMessage("push from kotlin server") + .build(), + ) + } + } + } + } + runDartClient("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() + } + } + runDartClient("wss", WSS_PORT, "requests", setOf("3", "4"), certPath) +} + private suspend fun withServer( start: () -> Unit, stop: () -> Unit, diff --git a/kotlin/crosstest/kotlin_go.kt b/kotlin/crosstest/kotlin_go.kt index d6d136c..1eef472 100644 --- a/kotlin/crosstest/kotlin_go.kt +++ b/kotlin/crosstest/kotlin_go.kt @@ -35,6 +35,7 @@ private const val HOST = "127.0.0.1" private const val TCP_PORT = 29390 private const val WS_PORT = 29392 private const val TLS_TCP_PORT = 29394 +private const val WSS_PORT = 29396 private const val WS_PATH = "/" private const val PROCESS_TIMEOUT_MS = 20_000L private const val SERVER_OBSERVATION_MS = 200L @@ -49,6 +50,8 @@ fun main() = runBlocking { runWs() delay(150) runTlsTcp() + delay(150) + runWss() println("PASS all kotlin-server/go-client crosstests passed") } catch (error: Throwable) { System.err.println("FAIL crosstest error=${error.message ?: error}") @@ -208,6 +211,60 @@ private suspend fun runTlsTcpRequests(serverCtx: SSLContext, certPath: String) { } } +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 go client" + received.complete(valid) + if (valid) { + launch { + client.send( + TestData.newBuilder() + .setIndex(200) + .setMessage("push from kotlin server") + .build(), + ) + } + } + } + } + runGoClient("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() + } + } + runGoClient("wss", WSS_PORT, "requests", setOf("3", "4"), certPath) +} + private suspend fun withServer( start: () -> Unit, stop: () -> Unit, diff --git a/kotlin/crosstest/kotlin_python.kt b/kotlin/crosstest/kotlin_python.kt index 21a819d..af818f0 100644 --- a/kotlin/crosstest/kotlin_python.kt +++ b/kotlin/crosstest/kotlin_python.kt @@ -35,6 +35,7 @@ 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 @@ -49,6 +50,8 @@ fun main() = runBlocking { runWs() delay(150) runTlsTcp() + delay(150) + runWss() println("PASS all kotlin-server/python-client crosstests passed") } catch (error: Throwable) { System.err.println("FAIL crosstest error=${error.message ?: error}") @@ -208,6 +211,60 @@ private suspend fun runTlsTcpRequests(serverCtx: SSLContext, certPath: String) { } } +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, diff --git a/kotlin/crosstest/kotlin_typescript.kt b/kotlin/crosstest/kotlin_typescript.kt index 9c33b68..837520a 100644 --- a/kotlin/crosstest/kotlin_typescript.kt +++ b/kotlin/crosstest/kotlin_typescript.kt @@ -35,6 +35,7 @@ private const val HOST = "127.0.0.1" private const val TCP_PORT = 29794 private const val WS_PORT = 29796 private const val TLS_TCP_PORT = 29798 +private const val WSS_PORT = 29800 private const val WS_PATH = "/" private const val PROCESS_TIMEOUT_MS = 20_000L private const val SERVER_OBSERVATION_MS = 200L @@ -49,6 +50,8 @@ fun main() = runBlocking { runWs() delay(150) runTlsTcp() + delay(150) + runWss() println("PASS all kotlin-server/typescript-client crosstests passed") } catch (error: Throwable) { System.err.println("FAIL crosstest error=${error.message ?: error}") @@ -208,6 +211,71 @@ private suspend fun runTlsTcpRequests(serverCtx: SSLContext, certPath: String) { } } +private suspend fun runWss() = coroutineScope { + val certFile = kotlinResourceFile("server.crt") + val keyFile = kotlinResourceFile("server.key") + val serverCtx = buildServerSslContext(certFile.path, keyFile.path) + withWssServer(serverCtx) { server -> + runWssSendPush(server, certFile.path) + } + delay(150) + withWssServer(serverCtx) { server -> + runWssRequests(server, certFile.path) + } +} + +private suspend fun withWssServer( + serverCtx: SSLContext, + body: suspend (WsServer) -> Unit, +) { + val server = WsServer(HOST, WSS_PORT, WS_PATH, sslContext = serverCtx) { conn -> + WsClient.forServer(conn, 0, 0, parserMap()) + } + server.start() + delay(100) + try { + body(server) + } 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 typescript client" + received.complete(valid) + if (valid) { + launch { + client.send( + TestData.newBuilder() + .setIndex(200) + .setMessage("push from kotlin server") + .build(), + ) + } + } + } + } + runTypescriptClient("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() + } + } + runTypescriptClient("wss", WSS_PORT, "requests", setOf("3", "4"), certPath) +} + private suspend fun withServer( start: () -> Unit, stop: () -> Unit, diff --git a/kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsServer.kt b/kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsServer.kt index 7177784..e10ed2e 100644 --- a/kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsServer.kt +++ b/kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsServer.kt @@ -4,32 +4,64 @@ import com.google.protobuf.MessageLite import kotlinx.coroutines.runBlocking import org.java_websocket.WebSocket import org.java_websocket.handshake.ClientHandshake +import org.java_websocket.server.DefaultSSLWebSocketServerFactory import org.java_websocket.server.WebSocketServer +import java.io.IOException import java.net.InetSocketAddress import java.nio.ByteBuffer import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import javax.net.ssl.SSLContext class WsServer( host: String, port: Int, private val path: String = "/", + private val sslContext: SSLContext? = null, private val newClient: (WebSocket) -> WsClient, ) : WebSocketServer(InetSocketAddress(host, port)) { private val clientByConn = ConcurrentHashMap() private val clients = CopyOnWriteArrayList() private val startedFlag = AtomicBoolean(false) + private val startLatch = CountDownLatch(1) + @Volatile + private var startError: Exception? = null var onClientConnected: (WsClient) -> Unit = {} + init { + setReuseAddr(true) + if (sslContext != null) { + setWebSocketFactory(DefaultSSLWebSocketServerFactory(sslContext)) + } + } + fun started(): Boolean = startedFlag.get() fun clients(): List = clients.toList() + fun port(): Int = port + override fun start() { - startedFlag.set(true) - super.start() + if (!startedFlag.compareAndSet(false, true)) return + try { + super.start() + } catch (error: RuntimeException) { + startedFlag.set(false) + throw error + } + if (!startLatch.await(5, TimeUnit.SECONDS)) { + startedFlag.set(false) + throw IOException("websocket server start timed out") + } + val error = startError + if (error != null) { + startedFlag.set(false) + throw IOException("websocket server failed to start", error) + } } override fun stop() { @@ -80,11 +112,18 @@ class WsServer( } override fun onError(conn: WebSocket?, ex: Exception) { - if (conn == null) return + if (conn == null) { + if (startLatch.count > 0) { + startError = ex + startLatch.countDown() + } + return + } clientByConn[conn]?.handleFailure() } override fun onStart() { + startLatch.countDown() } } diff --git a/typescript/src/ws_client.ts b/typescript/src/ws_client.ts index 1cb758e..cd7e3b2 100644 --- a/typescript/src/ws_client.ts +++ b/typescript/src/ws_client.ts @@ -10,11 +10,7 @@ export class WsClient extends BaseClient { private readonly ws: WebSocket; constructor(ws: WebSocket, intervalSec: number, waitSec: number, parserMap: ParserMap) { - super(intervalSec, waitSec, async () => { - if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { - ws.close(); - } - }); + super(intervalSec, waitSec, () => closeWebSocket(ws)); this.ws = ws; this.initBase(parserMap); @@ -119,3 +115,38 @@ function toUint8Array(data: RawData): Uint8Array { } return new Uint8Array(data); } + +async function closeWebSocket(ws: WebSocket): Promise { + if (ws.readyState === WebSocket.CLOSED) { + return; + } + + await new Promise((resolve) => { + const cleanup = () => { + clearTimeout(timer); + ws.off("close", onDone); + ws.off("error", onDone); + }; + const onDone = () => { + cleanup(); + resolve(); + }; + const timer = setTimeout(() => { + ws.terminate(); + onDone(); + }, 1000); + + ws.once("close", onDone); + ws.once("error", onDone); + + if (ws.readyState === WebSocket.CLOSING) { + return; + } + try { + ws.close(); + } catch { + ws.terminate(); + onDone(); + } + }); +}