기능: kotlin server crosstest에 WSS phase를 추가한다

WsServer TLS 지원 완료 후 crosstest에 누락됐던 WSS phase를 4개 orchestrator에 추가.
TypeScript는 handshake 안정성을 위해 phase별 서버 인스턴스를 분리했다.
WsServer init 강화(setReuseAddr, startError 전파), TypeScript closeWebSocket 안정화 포함.
This commit is contained in:
toki 2026-04-27 01:05:21 +09:00
parent c055387ace
commit c3bfb35a72
9 changed files with 926 additions and 8 deletions

View file

@ -0,0 +1,231 @@
<!-- task=kotlin_wss_crosstest plan=0 tag=KOTLIN_WSS_CROSSTEST -->
# 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
```

View file

@ -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에서 기존 추가된 것을 리팩토링한 것이 실제 내용.

View file

@ -0,0 +1,352 @@
<!-- task=kotlin_wss_crosstest plan=0 tag=KOTLIN_WSS_CROSSTEST -->
# 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<TestData>()}")
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<TestData>()}")
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<Boolean>()
server.onClientConnected = { client ->
addListenerTyped<TestData>(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<TestData, TestData>(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
```

View file

@ -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<Boolean>()
server.onClientConnected = { client ->
addListenerTyped<TestData>(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<TestData, TestData>(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,

View file

@ -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<Boolean>()
server.onClientConnected = { client ->
addListenerTyped<TestData>(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<TestData, TestData>(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,

View file

@ -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<Boolean>()
server.onClientConnected = { client ->
addListenerTyped<TestData>(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<TestData, TestData>(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,

View file

@ -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<Boolean>()
server.onClientConnected = { client ->
addListenerTyped<TestData>(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<TestData, TestData>(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,

View file

@ -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<WebSocket, WsClient>()
private val clients = CopyOnWriteArrayList<WsClient>()
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<WsClient> = 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()
}
}

View file

@ -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<void> {
if (ws.readyState === WebSocket.CLOSED) {
return;
}
await new Promise<void>((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();
}
});
}