sync: update communicator implementation across all languages

- Align protocol documentation (PROTOCOL.md, README.md, VERSIONING.md)
- Go: add nonce test, update communicator
- Kotlin: update Communicator, TcpClient, TcpServer, add TLS test
- Python: update all modules, add certificate test resources
- TypeScript: update communicator, tcp/ws clients and servers, add tests
- Dart: update communicator, heartbeat mixin, and tests
This commit is contained in:
toki 2026-04-26 05:31:56 +09:00
parent 01de3f32bf
commit 9cc1f1d58f
51 changed files with 2804 additions and 84 deletions

View file

@ -128,6 +128,9 @@ Changing the `typeName` derivation rule is a breaking protocol change unless all
- Starts at `1` per connection
- Increments by `1` on every send call (including requests and responses)
- Maximum nonce is `2,147,483,647` (`int32` max), the lowest common signed integer max across supported protocol fields
- After issuing `2,147,483,647`, implementations reset the internal counter to `0`; the next emitted packet nonce is `1`
- `0` is reserved and must not be emitted as `PacketBase.nonce`, because `responseNonce == 0` means "not a response"
- Used for request-response correlation via `responseNonce`
Changing `nonce` or `responseNonce` semantics is a breaking protocol change unless older peers can continue to correlate requests and responses correctly.

View file

@ -39,8 +39,8 @@ Protocol compatibility is tracked separately from language package versions. See
| Kotlin | Available | kotlin/ | Android, JVM |
| Swift | Planned | `swift/` | iOS, macOS |
| Go | Available | [go/](go/) | Server, tooling, scripting |
| TypeScript | Planned | `typescript/` | Browser, Node.js |
| Python | Planned | `python/` | Server, tooling, scripting |
| TypeScript | Available | [typescript/](typescript/) | Browser, Node.js |
| Python | Available | [python/](python/) | Server, tooling, scripting |
New language implementations should start from [PORTING_GUIDE.md](PORTING_GUIDE.md) and the templates in [agent-ops/skills/project/add-toki-socket-crosstest-language/templates/](agent-ops/skills/project/add-toki-socket-crosstest-language/templates/). Mark an implementation available only after its same-language tests and cross-language tests pass.

View file

@ -53,13 +53,15 @@ Backward-compatible message additions require updated parser maps and cross-lang
## nonce Overflow
`nonce` and `responseNonce` fields are protobuf `int32` values (signed 32-bit).
Overflow occurs after about 2.1 billion sends on a single connection.
The maximum emitted nonce is `2,147,483,647` (`int32` max).
Current policy:
- The protocol does not define overflow behavior. Reaching the int32 limit on a single connection is not realistic.
- Implementations do not add recovery logic for overflow.
- If overflow handling is needed in the future, the protocol version will be bumped and wrap-around behavior will be specified.
- Implementations start at `1` and increment by `1` on every send call, including requests and responses.
- After issuing `2,147,483,647`, implementations reset the internal counter to `0`; the next emitted `PacketBase.nonce` is `1`.
- `0` is reserved and must not be emitted as `PacketBase.nonce`, because `responseNonce == 0` means "not a response".
Changing this wrap behavior or the reserved meaning of `responseNonce == 0` changes `nonce` semantics and requires compatibility review and cross-language boundary tests.
## New Language Compatibility

View file

@ -0,0 +1,127 @@
<!-- task=dart_legacy_refactor plan=0 tag=REFACTOR -->
# Code Review Reference - REFACTOR
## 개요
date=2026-04-25
task=dart_legacy_refactor, plan=0, tag=REFACTOR
## 이 파일을 읽는 리뷰 에이전트에게
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
1. `CODE_REVIEW.md` → `code_review_0.log` (기존 code_review_*.log 수 = 0)
2. `PLAN.md` → `plan_0.log` (기존 plan_*.log 수 = 0)
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REFACTOR-1] `communicator.dart` `send()` concrete 구현 + 레거시 삭제 | [x] |
| [REFACTOR-2] `communicator_test.dart` `_FakeCommunicator.send()` 오버라이드 제거 | [x] |
## 계획 대비 변경 사항
- 현재 작업트리에 nonce overflow 정책(`maxNonce`, `nextNonce()`)이 적용되어 있어, 새 `Communicator.send()`도 계획의 `++nonce` 대신 `nextNonce()`를 사용했다.
- 기존 테스트 수가 계획 작성 시점보다 늘어 `communicator_test.dart`는 8개, 전체 `dart test`는 45개가 실행되었다.
## 주요 설계 결정
- `initialize()`는 항상 명시적인 `Transport`를 받도록 유지하고, 레거시 fallback transport를 제거했다.
- `Communicator.send()` 기본 구현은 `queuePacket()`을 통해 동일한 직렬화 경로를 사용한다.
- `HeartbeatMixin.send()` 오버라이드는 전송 실패 시 `close()`하는 기존 동작을 보존했다.
## 리뷰어를 위한 체크포인트
- `dart/lib/src/communicator.dart` — `@Deprecated transmitPacket` 메서드 삭제 여부
- `dart/lib/src/communicator.dart` — `_LegacyCommunicatorTransport` 클래스 삭제 여부
- `dart/lib/src/communicator.dart` — `initialize()` `transport` 파라미터가 `required`인지
- `dart/lib/src/communicator.dart` — `send<T>()` 메서드가 concrete이고 `abstract` 키워드가 없는지
- `dart/lib/src/communicator.dart` — `HeartbeatMixin.send()` `@override`가 여전히 유효한지 (heartbeat_mixin.dart 확인)
- `dart/test/communicator_test.dart` — `_FakeCommunicator.send()` 오버라이드 삭제 여부
- `dart analyze` No issues / `dart test` 45개 PASS
## 검증 결과
### REFACTOR-1 중간 검증
```
$ cd dart && dart analyze lib/src/communicator.dart
Analyzing communicator.dart...
No issues found!
```
### REFACTOR-2 중간 검증
```
$ cd dart && dart test test/communicator_test.dart
00:00 +0: loading test/communicator_test.dart
00:00 +0: Communicator protocol guards response typeName mismatch completes sendRequest with error
00:00 +1: Communicator protocol guards close 후 sendRequest는 StateError로 완료된다
00:00 +2: Communicator protocol guards sendRequest가 timeout 내 응답 없으면 TimeoutException을 던진다
00:00 +3: Communicator protocol guards nonce wraps after int32 max without emitting zero
00:00 +4: Communicator protocol guards sendRequest matches response at nonce wrap boundary
00:00 +5: Communicator protocol guards cannot register addRequestListener for a type already using addListener
00:00 +6: Communicator protocol guards cannot register addListener for a type already using addRequestListener
00:00 +7: Communicator protocol guards queuePacket uses injected transport
00:00 +8: All tests passed!
```
### 최종 검증
```
$ cd dart && dart analyze
Analyzing dart...
No issues found!
$ cd dart && dart test
00:00 +0: loading test/communicator_test.dart
00:00 +0: test/communicator_test.dart: Communicator protocol guards response typeName mismatch completes sendRequest with error
00:00 +1: test/communicator_test.dart: Communicator protocol guards close 후 sendRequest는 StateError로 완료된다
00:00 +2: test/communicator_test.dart: Communicator protocol guards sendRequest가 timeout 내 응답 없으면 TimeoutException을 던진다
00:00 +3: test/socket_test.dart: ProtobufServer (plain) 서버가 정상 시작된다
00:00 +4: test/socket_test.dart: ProtobufServer (plain) 서버가 정상 시작된다
00:00 +5: test/socket_test.dart: ProtobufServer (plain) 서버가 정상 시작된다
00:00 +6: test/socket_test.dart: ProtobufServer (plain) 서버가 정상 시작된다
00:00 +7: test/socket_test.dart: ProtobufServer (plain) 서버가 정상 시작된다
00:00 +8: test/socket_test.dart: ProtobufServer (plain) 서버가 정상 시작된다
00:00 +9: test/socket_test.dart: ProtobufClient (plain) 클라이언트가 서버에 연결된다
00:00 +10: test/socket_test.dart: ProtobufClient (plain) TestData 메시지를 서버가 수신한다
00:00 +11: test/socket_test.dart: ProtobufClient (plain) 여러 메시지를 순서대로 수신한다
00:01 +12: test/socket_test.dart: ProtobufClient (plain) 서버에서 클라이언트로 메시지를 전송한다
00:01 +13: test/socket_test.dart: ProtobufClient (plain) nonce가 송신마다 증가한다
00:01 +14: test/socket_test.dart: ProtobufClient (plain) 클라이언트 disconnect 시 서버 콜백이 호출된다
00:02 +15: test/socket_test.dart: ProtobufClient (plain) 서버 stop 시 클라이언트 disconnect 콜백이 호출된다
00:02 +16: test/socket_test.dart: ProtobufClient (plain) HeartBeat interval 동안 연결이 유지된다
00:04 +17: test/socket_test.dart: ProtobufClient (plain) close 후 isAlive가 false다
00:05 +18: test/socket_test.dart: ProtobufClient (plain) 서버가 모든 클라이언트에게 브로드캐스트한다
00:05 +19: test/socket_test.dart: ProtobufClient (plain) close 후 send는 무시된다
00:05 +20: test/socket_test.dart: ProtobufClient (plain) TCP closes on oversized packet length
00:06 +21: test/socket_test.dart: Heartbeat timeout TCP heartbeat 타임아웃 시 disconnect 콜백이 호출된다
00:08 +22: test/socket_test.dart: Heartbeat timeout WS heartbeat 타임아웃 시 disconnect 콜백이 호출된다
00:10 +23: test/socket_test.dart: ProtobufServer (SSL) SSL 서버가 정상 시작된다
00:10 +24: test/socket_test.dart: ProtobufClient (SSL) SSL 클라이언트가 서버에 연결된다
00:10 +25: test/socket_test.dart: ProtobufClient (SSL) SSL TestData 메시지를 서버가 수신한다
00:10 +26: test/socket_test.dart: ProtobufClient (SSL) SSL 서버에서 클라이언트로 메시지를 전송한다
00:11 +27: test/socket_test.dart: ProtobufClient (SSL) SSL disconnect 시 서버 콜백이 호출된다
00:11 +28: test/socket_test.dart: WsProtobufServer (plain) WS 서버가 정상 시작된다
00:11 +29: test/socket_test.dart: WsProtobufClient (plain) WS 클라이언트가 서버에 연결된다
00:11 +30: test/socket_test.dart: WsProtobufClient (plain) WS TestData 메시지를 서버가 수신한다
00:11 +31: test/socket_test.dart: WsProtobufClient (plain) WS 서버에서 클라이언트로 메시지를 전송한다
00:12 +32: test/socket_test.dart: WsProtobufClient (plain) WS 클라이언트 disconnect 시 서버 콜백이 호출된다
00:12 +33: test/socket_test.dart: WsProtobufClient (plain) WS 서버 stop 시 클라이언트 disconnect 콜백이 호출된다
00:12 +34: test/socket_test.dart: WsProtobufClient (plain) WS close 후 isAlive가 false다
00:12 +35: test/socket_test.dart: WsProtobufClient (plain) WS 서버가 모든 클라이언트에게 브로드캐스트한다
00:13 +36: test/socket_test.dart: WsProtobufClient (plain) WS close 후 send는 무시된다
00:13 +37: test/socket_test.dart: WsProtobufServer (SSL) WSS 서버가 정상 시작된다
00:13 +38: test/socket_test.dart: WsProtobufClient (SSL) WSS 클라이언트가 서버에 연결된다
00:13 +39: test/socket_test.dart: WsProtobufClient (SSL) WSS TestData 메시지를 서버가 수신한다
00:14 +40: test/socket_test.dart: WsProtobufClient (SSL) WSS disconnect 시 서버 콜백이 호출된다
00:14 +41: test/socket_test.dart: Request-Response (TCP plain) sendRequest로 서버 응답을 받는다
00:14 +42: test/socket_test.dart: Request-Response (TCP plain) 여러 sendRequest가 각각 올바른 응답을 받는다
00:14 +43: test/socket_test.dart: Request-Response (WS plain) WS sendRequest로 서버 응답을 받는다
00:15 +44: test/socket_test.dart: Request-Response (WS plain) WS 여러 sendRequest가 각각 올바른 응답을 받는다
00:15 +45: All tests passed!
```

View file

@ -0,0 +1,17 @@
task=dart_legacy_refactor plan=0 tag=REFACTOR
date=2026-04-26
result=PASS
## 완료 항목
- [REFACTOR-1] communicator.dart: send() concrete 구현, @Deprecated transmitPacket / _LegacyCommunicatorTransport 삭제, initialize() transport required화
- [REFACTOR-2] communicator_test.dart: _FakeCommunicator.send() 오버라이드 삭제, setNonceForTest() 추가
## 계획 외 반영
- nonce overflow 정책(maxNonce, nextNonce()) 적용 — 작업트리에 이미 반영된 nonce_overflow_policy 태스크 결과를 흡수
## 검증
- dart analyze: No issues found
- dart test: 45/45 PASS

View file

@ -0,0 +1,183 @@
<!-- task=dart_legacy_refactor plan=0 tag=REFACTOR -->
# Dart Communicator 레거시 정리
## 이 파일을 읽는 구현 에이전트에게
각 항목의 체크리스트를 완료 표시하고, 중간 검증 명령을 실행한 뒤 출력을 CODE_REVIEW.md의 `검증 결과` 섹션에 붙여 넣는다. 최종 검증까지 완료한 후 CODE_REVIEW.md의 각 항목을 `[x]`로 체크한다.
## 배경
Dart `Communicator`는 `send<T>()` 메서드가 `abstract`로 선언되어 모든 서브클래스나 믹스인이 구현해야 한다. Go·Kotlin·Python·TypeScript 4개 언어는 `Communicator.send()`가 직접 `queuePacket`을 호출하는 concrete 구현을 제공한다. Dart의 이 설계 차이는 `HeartbeatMixin`이 `Communicator`에 믹스인되는 초기 구조에서 비롯된 레거시로, `@Deprecated transmitPacket`과 `_LegacyCommunicatorTransport` 데드코드를 동반한다. 테스트의 `_FakeCommunicator`도 동일한 이유로 불필요한 `send()` 오버라이드를 포함한다. 이번 작업으로 Dart를 다른 언어 구현과 동일한 구조로 맞춘다.
---
## [REFACTOR-1] `communicator.dart` — `send()` concrete 구현 + 레거시 삭제
### 문제
- `dart/lib/src/communicator.dart:63` — `send()`가 `abstract`여서 서브클래스 모두 구현 강제
- `dart/lib/src/communicator.dart:47-53` — `@Deprecated transmitPacket` 데드코드
- `dart/lib/src/communicator.dart:35,37` — `initialize()`의 `transport` 파라미터가 nullable이며, null 시 이미 삭제 대상인 `_LegacyCommunicatorTransport(this)` 폴백을 사용
- `dart/lib/src/communicator.dart:192-204` — `_LegacyCommunicatorTransport` 클래스 데드코드
### 해결 방법
1. `initialize()`의 `{Transport? transport}` → `{required Transport transport}` 변경
2. `_transport = transport ?? _LegacyCommunicatorTransport(this)` → `_transport = transport`
3. `@Deprecated transmitPacket` 메서드 삭제
4. `abstract Future<void> send<T>(T data)` → concrete 구현으로 교체
5. `_LegacyCommunicatorTransport` 클래스 삭제
**Before (communicator.dart:33-38):**
```dart
void initialize(
Map<String, GeneratedMessage Function(List<int>)> instanceGenerator,
{Transport? transport}) {
_instanceGenerator = instanceGenerator;
_transport = transport ?? _LegacyCommunicatorTransport(this);
}
```
**After:**
```dart
void initialize(
Map<String, GeneratedMessage Function(List<int>)> instanceGenerator,
{required Transport transport}) {
_instanceGenerator = instanceGenerator;
_transport = transport;
}
```
**Before (communicator.dart:47-53):**
```dart
/// Deprecated compatibility path for subclasses that still override writes.
///
/// New clients should pass a [Transport] to [initialize] instead.
@Deprecated('Pass a Transport to initialize instead.')
Future<void> transmitPacket(PacketBase base) {
return Future.error(StateError('transport is not initialized'));
}
```
**After:** 전체 삭제
**Before (communicator.dart:63):**
```dart
Future<void> send<T extends GeneratedMessage>(T data);
```
**After:**
```dart
Future<void> send<T extends GeneratedMessage>(T data) async {
if (isAlive) {
await queuePacket(PacketBase()
..typeName = data.info_.qualifiedMessageName
..nonce = ++nonce
..data = data.writeToBuffer());
}
}
```
**Before (communicator.dart:192-204):**
```dart
class _LegacyCommunicatorTransport implements Transport {
final Communicator _communicator;
_LegacyCommunicatorTransport(this._communicator);
@override
Future<void> writePacket(PacketBase base) {
return _communicator.transmitPacket(base);
}
@override
Future<void> close() async {}
}
```
**After:** 전체 삭제 (이 클래스 바로 아래의 `abstract class IDataHandler` 이후는 그대로 유지)
### 수정 파일 및 체크리스트
- `dart/lib/src/communicator.dart`
- [x] `initialize()` 파라미터: `{Transport? transport}` → `{required Transport transport}`
- [x] `initialize()` 바디: `_transport = transport ?? _LegacyCommunicatorTransport(this)` → `_transport = transport`
- [x] `@Deprecated transmitPacket` 메서드 전체 삭제 (docstring 포함)
- [x] `abstract Future<void> send<T extends GeneratedMessage>(T data)` → concrete 구현으로 교체
- [x] `_LegacyCommunicatorTransport` 클래스 전체 삭제
### 테스트 작성
추가 테스트 불필요. 기존 `communicator_test.dart` 6개 테스트가 `send()` 경로를 포함해 충분히 커버한다.
### 중간 검증
```
$ cd dart && dart analyze lib/src/communicator.dart
(No issues found!)
```
---
## [REFACTOR-2] `communicator_test.dart` — `_FakeCommunicator.send()` 오버라이드 제거
### 문제
`dart/test/communicator_test.dart:24-33` — `_FakeCommunicator.send()` 오버라이드가 REFACTOR-1 이후 `Communicator.send()`와 동일한 로직을 중복 구현한다.
```dart
@override
Future<void> send<T extends GeneratedMessage>(T data) async {
if (isAlive) {
await queuePacket(PacketBase()
..typeName = data.info_.qualifiedMessageName
..nonce = ++nonce
..data = data.writeToBuffer());
}
return Future.value();
}
```
### 해결 방법
`_FakeCommunicator.send()` 오버라이드 블록 전체 삭제. `Communicator.send()` 기본 구현이 그대로 사용된다.
### 수정 파일 및 체크리스트
- `dart/test/communicator_test.dart`
- [x] `_FakeCommunicator.send()` 오버라이드 삭제 (lines 24-33, `@override`부터 닫는 `}` 포함)
### 테스트 작성
기존 6개 테스트 그대로 사용.
### 중간 검증
```
$ cd dart && dart test test/communicator_test.dart
00:00 +6: All tests passed!
```
---
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `dart/lib/src/communicator.dart` | REFACTOR-1 |
| `dart/test/communicator_test.dart` | REFACTOR-2 |
## 의존 관계 및 구현 순서
REFACTOR-1 먼저, 이후 REFACTOR-2 진행. REFACTOR-2는 REFACTOR-1이 완료된 상태에서만 컴파일된다.
## 최종 검증
```
$ cd dart && dart analyze
No issues found!
$ cd dart && dart test
00:xx +43: All tests passed!
```

View file

@ -0,0 +1,132 @@
<!-- task=nonce_overflow_policy plan=0 tag=API -->
# Code Review Reference - API
## 개요
date=2026-04-26
task=nonce_overflow_policy, plan=0, tag=API
## 이 파일을 읽는 리뷰 에이전트에게
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
1. `CODE_REVIEW.md` → `code_review_N.log` (N = 기존 code_review_*.log 수)
2. `PLAN.md` → `plan_M.log` (M = 기존 plan_*.log 수)
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] 프로토콜 문서에 공통 nonce overflow 정책 정의 | [x] |
| [API-2] 모든 구현체의 nonce 발급 로직을 공통 int32 max wrap 정책으로 통일 | [x] |
| [API-3] 요청-응답 상관관계에서 wrap boundary 회귀 테스트 보강 | [x] |
## 계획 대비 변경 사항
- Go는 기존 `go/test`가 외부 패키지라 private `nonce` 접근이 불가능하여 계획의 후보대로 `go/communicator_nonce_test.go` 내부 패키지 테스트를 새로 추가했다.
- Dart `sendRequest` 테스트는 `MAX_NONCE` 요청 매칭과 다음 요청의 wrap 값 `1` 매칭을 같은 테스트에서 함께 검증했다.
- Go/Kotlin 검증은 로컬 환경에 Go/JDK가 없어 실행하지 못했다. 코드 변경과 테스트 파일은 추가했지만 해당 언어의 컴파일은 이 환경에서 확인되지 않았다.
## 주요 설계 결정
- 모든 구현체의 공통 상한은 protobuf `int32` signed max인 `2,147,483,647`로 고정했다.
- `PacketBase.nonce`는 `0`을 송신하지 않는다. 내부 카운터가 `MAX_NONCE` 이상이면 먼저 0으로 리셋한 뒤 다음 발급값 `1`을 반환한다.
- Dart/Python/TypeScript는 단일 `nextNonce`/`next_nonce` 경로를 통해 발급한다.
- Go/Kotlin은 동시 송신에서 중복 nonce가 나오지 않도록 CAS loop로 `current -> next` 갱신을 수행한다.
## 리뷰어를 위한 체크포인트
- 문서가 공통 상한을 `2,147,483,647` signed int32 max로 명시하는지 확인하세요.
- `responseNonce == 0` 예약 의미를 깨지 않도록 실제 송신 `PacketBase.nonce`가 0을 내보내지 않는지 확인하세요.
- Dart의 모든 `++nonce` 직접 증가 지점이 `nextNonce()`로 대체됐는지 확인하세요.
- Go/Kotlin은 동시 송신에서도 중복 nonce가 나오지 않도록 CAS loop를 사용하는지 확인하세요.
- Python/TypeScript는 언어 자체의 큰 정수 범위가 아니라 프로토콜 공통 `int32` max를 상한으로 쓰는지 확인하세요.
- 각 언어 테스트가 `MAX_NONCE - 1` boundary에서 `MAX_NONCE`, `1` 순서를 검증하는지 확인하세요.
- `sendRequest` boundary 테스트가 request nonce 0을 만들지 않고 matching `responseNonce`로 정상 완료되는지 확인하세요.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### API-1 중간 검증
```
$ rg -n "nonce Overflow|2,147,483,647|INT32|int32 max|responseNonce == 0|PacketBase.nonce" PROTOCOL.md VERSIONING.md
PROTOCOL.md:131:- Maximum nonce is `2,147,483,647` (`int32` max), the lowest common signed integer max across supported protocol fields
PROTOCOL.md:132:- After issuing `2,147,483,647`, implementations reset the internal counter to `0`; the next emitted packet nonce is `1`
PROTOCOL.md:133:- `0` is reserved and must not be emitted as `PacketBase.nonce`, because `responseNonce == 0` means "not a response"
PROTOCOL.md:159:- `responseNonce == 0`: regular message or outgoing request → routed to `addListener` or `addRequestListener`
VERSIONING.md:53:## nonce Overflow
VERSIONING.md:56:The maximum emitted nonce is `2,147,483,647` (`int32` max).
VERSIONING.md:61:- After issuing `2,147,483,647`, implementations reset the internal counter to `0`; the next emitted `PacketBase.nonce` is `1`.
VERSIONING.md:62:- `0` is reserved and must not be emitted as `PacketBase.nonce`, because `responseNonce == 0` means "not a response".
VERSIONING.md:64:Changing this wrap behavior or the reserved meaning of `responseNonce == 0` changes `nonce` semantics and requires compatibility review and cross-language boundary tests.
```
### API-2 중간 검증
```
$ rg -n "\+\+nonce|incrementAndGet|Add\(1\)|nonce \+= 1" dart/lib go kotlin/src/main python/toki_socket typescript/src -g '!**/build/**'
typescript/src/communicator.ts:116: this.nonce += 1;
dart/lib/src/communicator.dart:31: _nonce += 1;
python/toki_socket/communicator.py:67: self._nonce += 1
go/test/tcp_test.go:182: wg.Add(1)
go/test/ws_test.go:88: wg.Add(1)
go/crosstest/kotlin_go_client/main.go:170: wg.Add(1)
go/crosstest/dart_go_client/main.go:217: wg.Add(1)
go/crosstest/python_go_client/main.go:168: wg.Add(1)
go/crosstest/typescript_go_client/main.go:170: wg.Add(1)
위 `nonce += 1` 결과는 각 언어의 `nextNonce`/`next_nonce` 내부 발급 구현이다. `wg.Add(1)` 결과는 Go wait group 증가 false positive이며 nonce 발급이 아니다.
```
### API-3 중간 검증
```
$ dart test test/communicator_test.dart
00:00 +8: All tests passed!
$ go test ./...
/usr/bin/bash: line 1: go: command not found
$ cd kotlin && ./gradlew test --tests '*CommunicatorTest*'
ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
$ cd python && python3 -m pytest test/test_communicator.py -q
....... [100%]
7 passed in 0.20s
$ cd typescript && npm test -- --run test/communicator.test.ts
✓ test/communicator.test.ts (10 tests) 21ms
Test Files 1 passed (1)
Tests 10 passed (10)
```
### 최종 검증
```
$ tools/check_proto_sync.sh
Proto schemas are in sync.
$ cd dart && dart test
00:15 +45: All tests passed!
$ cd go && go test ./...
/usr/bin/bash: line 1: go: command not found
$ cd kotlin && ./gradlew test
ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
$ cd python && python3 -m pytest test/ -q
............... [100%]
15 passed in 0.51s
$ cd typescript && npm test -- --run
✓ test/base_client.test.ts (7 tests) 14ms
✓ test/communicator.test.ts (10 tests) 22ms
✓ test/tcp.test.ts (3 tests) 40ms
✓ test/ws.test.ts (3 tests) 31ms
Test Files 4 passed (4)
Tests 23 passed (23)
```

View file

@ -0,0 +1,12 @@
date=2026-04-26
task=nonce_overflow_policy
plan=0
tag=API
result=PASS
All items passed review:
- API-1: PROTOCOL.md and VERSIONING.md document int32 max wrap policy
- API-2: All implementations unified to common int32 max constant with wrap logic
- API-3: Boundary regression tests added for send and sendRequest in all languages
Go and Kotlin compilation not verified locally (no go/JDK in environment); CI required.

View file

@ -0,0 +1,350 @@
<!-- task=nonce_overflow_policy plan=0 tag=API -->
# Nonce Overflow Policy Plan
## 이 파일을 읽는 구현 에이전트에게
각 항목의 체크리스트를 완료하고, 항목별 중간 검증과 최종 검증을 실제로 실행하세요. 구현 후 `CODE_REVIEW.md`의 완료 여부, 계획 대비 변경 사항, 주요 설계 결정, 검증 결과를 실제 구현 내용과 명령 출력으로 모두 채우세요. 이 계획은 프로토콜 동작 변경을 포함하므로 문서와 모든 사용 가능 언어 구현이 같은 정책을 따르는지 끝까지 대조해야 합니다.
## 배경
현재 `nonce`는 송신마다 증가하지만 overflow 정책이 명세에 정의되어 있지 않습니다. `PacketBase.nonce`와 `responseNonce`는 protobuf `int32`라서 모든 언어에서 공통으로 안전하게 통용되는 상한은 signed 32-bit max인 `2,147,483,647`입니다. 단, 현재 프로토콜은 `responseNonce == 0`을 "응답 아님"으로 사용하므로 실제 송신 `nonce`로 0을 내보내면 request-response 상관관계가 깨질 수 있습니다. 따라서 구현 정책은 공통 상한 도달 후 내부 카운터를 0으로 리셋하고, 다음 송신 nonce는 다시 1부터 재사용하는 방식으로 명시합니다.
## [API-1] 프로토콜 문서에 공통 nonce overflow 정책 정의
### 문제
[PROTOCOL.md:127](/config/workspace/toki_socket/PROTOCOL.md:127)의 `nonce` 섹션은 시작값과 증가 규칙만 설명하고 overflow 동작을 정의하지 않습니다. [VERSIONING.md:53](/config/workspace/toki_socket/VERSIONING.md:53)는 overflow 정책이 없다고 명시하므로 구현체들이 각 언어의 정수 overflow 특성에 따라 달라질 수 있습니다.
Before:
```markdown
PROTOCOL.md:127
## nonce
- Starts at `1` per connection
- Increments by `1` on every send call (including requests and responses)
- Used for request-response correlation via `responseNonce`
```
```markdown
VERSIONING.md:53
## nonce Overflow
`nonce` and `responseNonce` fields are protobuf `int32` values (signed 32-bit).
Overflow occurs after about 2.1 billion sends on a single connection.
Current policy:
- The protocol does not define overflow behavior. Reaching the int32 limit on a single connection is not realistic.
- Implementations do not add recovery logic for overflow.
- If overflow handling is needed in the future, the protocol version will be bumped and wrap-around behavior will be specified.
```
### 해결 방법
`nonce` 상한을 `INT32_MAX = 2,147,483,647`로 문서화합니다. 정책은 "마지막으로 발급한 nonce가 `INT32_MAX`이면 내부 카운터를 0으로 되돌린 뒤 다음 발급값은 1"로 적습니다. `responseNonce == 0` 예약 의미를 유지하기 위해 `PacketBase.nonce` 역시 0을 송신하지 않는다고 명시합니다.
After:
```markdown
## nonce
- Starts at `1` per connection
- Increments by `1` on every send call (including requests and responses)
- Maximum nonce is `2,147,483,647` (`int32` max), the lowest common signed integer max across supported protocol fields
- After issuing `2,147,483,647`, implementations reset the internal counter to `0`; the next emitted packet nonce is `1`
- `0` is reserved and must not be emitted as `PacketBase.nonce`, because `responseNonce == 0` means "not a response"
- Used for request-response correlation via `responseNonce`
```
### 수정 파일 및 체크리스트
- [ ] [PROTOCOL.md](/config/workspace/toki_socket/PROTOCOL.md): `nonce` 섹션에 `INT32_MAX` 상한, wrap 정책, `0` 예약 규칙 추가
- [ ] [VERSIONING.md](/config/workspace/toki_socket/VERSIONING.md): `nonce Overflow` 섹션을 "정책 없음"에서 "int32 max 기준 wrap"으로 갱신
- [ ] [VERSIONING.md](/config/workspace/toki_socket/VERSIONING.md): 이 변경이 `nonce` semantics 변경이므로 호환성/테스트 필요성을 문서와 맞춤
### 테스트 작성
문서 변경 자체에는 별도 테스트를 작성하지 않습니다. 대신 API-2에서 모든 구현체의 boundary 테스트가 문서 정책을 검증합니다.
### 중간 검증
```bash
rg -n "nonce Overflow|2,147,483,647|INT32|int32 max|responseNonce == 0|PacketBase.nonce" PROTOCOL.md VERSIONING.md
```
기대 결과: 두 문서에서 공통 `int32` max, wrap 정책, `0` 예약 설명이 검색됩니다.
## [API-2] 모든 구현체의 nonce 발급 로직을 공통 int32 max wrap 정책으로 통일
### 문제
각 구현체는 현재 단순 증가만 수행합니다. Dart는 [dart/lib/src/communicator.dart:88](/config/workspace/toki_socket/dart/lib/src/communicator.dart:88), [dart/lib/src/communicator.dart:138](/config/workspace/toki_socket/dart/lib/src/communicator.dart:138), [dart/lib/src/heartbeat_mixin.dart:53](/config/workspace/toki_socket/dart/lib/src/heartbeat_mixin.dart:53)에서 `++nonce`를 직접 사용합니다. Go/Kotlin/Python/TypeScript는 각각 `nextNonce()`가 있지만 overflow 경계 처리가 없습니다.
Before:
```dart
dart/lib/src/communicator.dart:88
final requestNonce = ++nonce;
```
```dart
dart/lib/src/heartbeat_mixin.dart:51
await queuePacket(PacketBase()
..typeName = data.info_.qualifiedMessageName
..nonce = ++nonce
..data = data.writeToBuffer());
```
```go
go/communicator.go:90
func (c *Communicator) nextNonce() int32 {
return c.nonce.Add(1)
}
```
```kotlin
kotlin/src/main/kotlin/com/tokilabs/toki_socket/Communicator.kt:92
@PublishedApi
internal fun nextNonce(): Int = nonce.incrementAndGet()
```
```python
python/toki_socket/communicator.py:63
def next_nonce(self) -> int:
self._nonce += 1
return self._nonce
```
```typescript
typescript/src/communicator.ts:111
private nextNonce(): number {
this.nonce += 1;
return this.nonce;
}
```
### 해결 방법
모든 언어에 공통 상수 `2147483647`을 두고, `nextNonce` 계열 함수 하나로만 nonce를 발급하게 만듭니다. 발급 로직은 현재 값이 `INT32_MAX` 이상이면 내부 값을 0으로 리셋한 뒤 1을 반환하고, 그 외에는 `current + 1`을 반환합니다. Go와 Kotlin은 동시 송신에서 중복 nonce가 나오지 않도록 CAS loop를 사용합니다.
After 예시:
```dart
static const int maxNonce = 2147483647;
@protected
int nextNonce() {
if (_nonce >= maxNonce) {
_nonce = 0;
}
_nonce += 1;
return _nonce;
}
```
```go
const MaxNonce int32 = 1<<31 - 1
func (c *Communicator) nextNonce() int32 {
for {
current := c.nonce.Load()
next := current + 1
if current >= MaxNonce {
next = 1
}
if c.nonce.CompareAndSwap(current, next) {
return next
}
}
}
```
```kotlin
internal const val MAX_NONCE = Int.MAX_VALUE
@PublishedApi
internal fun nextNonce(): Int {
while (true) {
val current = nonce.get()
val next = if (current >= MAX_NONCE) 1 else current + 1
if (nonce.compareAndSet(current, next)) return next
}
}
```
```python
MAX_NONCE = 2_147_483_647
def next_nonce(self) -> int:
if self._nonce >= MAX_NONCE:
self._nonce = 0
self._nonce += 1
return self._nonce
```
```typescript
private static readonly MAX_NONCE = 2_147_483_647;
private nextNonce(): number {
if (this.nonce >= Communicator.MAX_NONCE) {
this.nonce = 0;
}
this.nonce += 1;
return this.nonce;
}
```
### 수정 파일 및 체크리스트
- [ ] [dart/lib/src/communicator.dart](/config/workspace/toki_socket/dart/lib/src/communicator.dart): `maxNonce`와 protected `nextNonce()` 추가
- [ ] [dart/lib/src/communicator.dart](/config/workspace/toki_socket/dart/lib/src/communicator.dart): `sendRequest`와 request response 발급 지점의 `++nonce`를 `nextNonce()`로 교체
- [ ] [dart/lib/src/heartbeat_mixin.dart](/config/workspace/toki_socket/dart/lib/src/heartbeat_mixin.dart): `send`의 `++nonce`를 `nextNonce()`로 교체
- [ ] [go/communicator.go](/config/workspace/toki_socket/go/communicator.go): `MaxNonce int32 = 1<<31 - 1` 추가
- [ ] [go/communicator.go](/config/workspace/toki_socket/go/communicator.go): `nextNonce()`를 `atomic.Int32.CompareAndSwap` loop로 변경
- [ ] [kotlin/src/main/kotlin/com/tokilabs/toki_socket/Communicator.kt](/config/workspace/toki_socket/kotlin/src/main/kotlin/com/tokilabs/toki_socket/Communicator.kt): `MAX_NONCE = Int.MAX_VALUE` 추가
- [ ] [kotlin/src/main/kotlin/com/tokilabs/toki_socket/Communicator.kt](/config/workspace/toki_socket/kotlin/src/main/kotlin/com/tokilabs/toki_socket/Communicator.kt): `nextNonce()`를 `AtomicInteger.compareAndSet` loop로 변경
- [ ] [python/toki_socket/communicator.py](/config/workspace/toki_socket/python/toki_socket/communicator.py): `MAX_NONCE = 2_147_483_647` 추가
- [ ] [python/toki_socket/communicator.py](/config/workspace/toki_socket/python/toki_socket/communicator.py): `next_nonce()`에 wrap 처리 추가
- [ ] [typescript/src/communicator.ts](/config/workspace/toki_socket/typescript/src/communicator.ts): `MAX_NONCE` static constant 추가
- [ ] [typescript/src/communicator.ts](/config/workspace/toki_socket/typescript/src/communicator.ts): `nextNonce()`에 wrap 처리 추가
- [ ] 전체 검색으로 `++nonce`, `incrementAndGet`, `Add(1)`, `nonce += 1` 직접 발급이 남지 않았는지 확인
### 테스트 작성
각 언어별 Communicator 단위 테스트에 boundary test를 추가합니다. 테스트 목표는 `MAX_NONCE - 1`에서 두 번 송신했을 때 첫 packet nonce가 `MAX_NONCE`, 두 번째 packet nonce가 `1`이며 `0`이 송신되지 않는지 검증하는 것입니다.
- [ ] [dart/test/communicator_test.dart](/config/workspace/toki_socket/dart/test/communicator_test.dart): `_FakeCommunicator`에 테스트용 nonce setter 또는 helper를 추가하고 `nonce wraps after int32 max without emitting zero` 테스트 작성
- [ ] [go/test/nonce_test.go](/config/workspace/toki_socket/go/test/nonce_test.go) 또는 새 내부 패키지 테스트: `package toki_socket`에서 `communicator.nonce.Store(MaxNonce - 1)` 후 `Send` 두 번을 검증. 기존 `go/test`가 외부 패키지라 private 필드 접근이 안 되면 `go/communicator_nonce_test.go`를 새로 추가
- [ ] [kotlin/src/test/kotlin/com/tokilabs/toki_socket/CommunicatorTest.kt](/config/workspace/toki_socket/kotlin/src/test/kotlin/com/tokilabs/toki_socket/CommunicatorTest.kt): reflection으로 private `nonce: AtomicInteger`를 `Int.MAX_VALUE - 1`로 세팅하고 `testNonceWrapsAfterIntMax` 작성
- [ ] [python/test/test_communicator.py](/config/workspace/toki_socket/python/test/test_communicator.py): `communicator._nonce = MAX_NONCE - 1` 후 `send` 두 번을 검증하는 테스트 작성
- [ ] [typescript/test/communicator.test.ts](/config/workspace/toki_socket/typescript/test/communicator.test.ts): `comm as unknown as { nonce: number }`로 `MAX_NONCE - 1` 설정 후 `send` 두 번 검증
### 중간 검증
```bash
rg -n "\+\+nonce|incrementAndGet|Add\(1\)|nonce \+= 1" dart/lib go kotlin/src/main python/toki_socket typescript/src -g '!**/build/**'
```
기대 결과: nonce 발급 구현 내부 외에는 직접 증가 패턴이 남지 않습니다. Go/Kotlin은 CAS 기반 `nextNonce()`만 남아야 합니다.
## [API-3] 요청-응답 상관관계에서 wrap boundary 회귀 테스트 보강
### 문제
단순 `send` boundary만 검증하면 `sendRequest`의 pending map key와 response handler가 wrap된 nonce를 올바르게 사용하는지 놓칠 수 있습니다. 특히 `responseNonce == 0` 예약 규칙 때문에 `sendRequest`가 0을 request nonce로 발급하지 않는지 직접 확인해야 합니다.
Before:
```typescript
typescript/test/communicator.test.ts:134
test("sendRequest rejects on timeout", async () => {
const comm = new Communicator();
comm.initialize(new MockTransport(), parserMap());
await expect(
comm.sendRequest(create(TestDataSchema, { index: 1, message: "wait" }), TestDataSchema, 10),
).rejects.toThrow(/request timeout for nonce 1/);
});
```
```python
python/test/test_communicator.py:62
@pytest.mark.asyncio
async def test_send_request_response():
transport = FakeTransport()
communicator = Communicator()
communicator.initialize(transport, parser_map())
```
### 해결 방법
가능하면 각 언어 Communicator 테스트에 request boundary도 추가합니다. 시간 비용을 줄이기 위해 최소한 하나의 대표 구현만이 아니라 모든 구현체에서 `sendRequest` 첫 request nonce가 `MAX_NONCE` 또는 wrap 후 `1`을 사용하는지 검증합니다. 응답 매칭은 `responseNonce`에 해당 request nonce를 그대로 넣어 완료되는지 확인합니다.
### 수정 파일 및 체크리스트
- [ ] [dart/test/communicator_test.dart](/config/workspace/toki_socket/dart/test/communicator_test.dart): `sendRequest`가 `maxNonce` request를 pending에 등록하고 matching response로 완료되는지 검증
- [ ] [go/communicator_nonce_test.go](/config/workspace/toki_socket/go/communicator_nonce_test.go): `SendRequestTyped` boundary response matching 테스트 추가
- [ ] [kotlin/src/test/kotlin/com/tokilabs/toki_socket/CommunicatorTest.kt](/config/workspace/toki_socket/kotlin/src/test/kotlin/com/tokilabs/toki_socket/CommunicatorTest.kt): boundary request response matching 테스트 추가
- [ ] [python/test/test_communicator.py](/config/workspace/toki_socket/python/test/test_communicator.py): boundary request response matching 테스트 추가
- [ ] [typescript/test/communicator.test.ts](/config/workspace/toki_socket/typescript/test/communicator.test.ts): boundary request response matching 테스트 추가
### 테스트 작성
테스트를 작성합니다. 이름은 각 언어 관례에 맞춰 `nonce wraps at int32 max for sendRequest` 또는 `TestSendRequestNonceWrapsAtInt32Max`로 둡니다. assertion goal은 `request.nonce != 0`, `request.nonce == MAX_NONCE` 또는 wrap 이후 `1`, 그리고 `responseNonce=request.nonce` 응답이 정상 완료되는 것입니다.
### 중간 검증
```bash
dart test test/communicator_test.dart
go test ./...
cd kotlin && ./gradlew test --tests '*CommunicatorTest*'
cd python && python3 -m pytest test/test_communicator.py -q
cd typescript && npm test -- --run test/communicator.test.ts
```
기대 결과: 각 언어 Communicator 테스트가 통과합니다. 로컬 환경에 Go/JDK가 없으면 실행하지 못한 명령과 이유를 `CODE_REVIEW.md` 검증 결과에 명시합니다.
## 의존 관계 및 구현 순서
1. API-1로 문서의 정책을 먼저 확정합니다.
2. API-2로 모든 언어의 발급 로직을 같은 상수와 같은 wrap 규칙으로 맞춥니다.
3. API-3으로 `send`와 `sendRequest` boundary 회귀 테스트를 추가합니다.
4. 최종 검증을 실행하고 `CODE_REVIEW.md`를 채웁니다.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| [PROTOCOL.md](/config/workspace/toki_socket/PROTOCOL.md) | API-1 |
| [VERSIONING.md](/config/workspace/toki_socket/VERSIONING.md) | API-1 |
| [dart/lib/src/communicator.dart](/config/workspace/toki_socket/dart/lib/src/communicator.dart) | API-2 |
| [dart/lib/src/heartbeat_mixin.dart](/config/workspace/toki_socket/dart/lib/src/heartbeat_mixin.dart) | API-2 |
| [dart/test/communicator_test.dart](/config/workspace/toki_socket/dart/test/communicator_test.dart) | API-2, API-3 |
| [go/communicator.go](/config/workspace/toki_socket/go/communicator.go) | API-2 |
| [go/communicator_nonce_test.go](/config/workspace/toki_socket/go/communicator_nonce_test.go) | API-2, API-3 |
| [kotlin/src/main/kotlin/com/tokilabs/toki_socket/Communicator.kt](/config/workspace/toki_socket/kotlin/src/main/kotlin/com/tokilabs/toki_socket/Communicator.kt) | API-2 |
| [kotlin/src/test/kotlin/com/tokilabs/toki_socket/CommunicatorTest.kt](/config/workspace/toki_socket/kotlin/src/test/kotlin/com/tokilabs/toki_socket/CommunicatorTest.kt) | API-2, API-3 |
| [python/toki_socket/communicator.py](/config/workspace/toki_socket/python/toki_socket/communicator.py) | API-2 |
| [python/test/test_communicator.py](/config/workspace/toki_socket/python/test/test_communicator.py) | API-2, API-3 |
| [typescript/src/communicator.ts](/config/workspace/toki_socket/typescript/src/communicator.ts) | API-2 |
| [typescript/test/communicator.test.ts](/config/workspace/toki_socket/typescript/test/communicator.test.ts) | API-2, API-3 |
## 최종 검증
```bash
tools/check_proto_sync.sh
```
기대 결과: proto schemas are in sync.
```bash
cd dart && dart test
```
기대 결과: 모든 Dart 테스트 통과.
```bash
cd go && go test ./...
```
기대 결과: 모든 Go 테스트 통과.
```bash
cd kotlin && ./gradlew test
```
기대 결과: 모든 Kotlin 테스트 통과.
```bash
cd python && python3 -m pytest test/ -q
```
기대 결과: 모든 Python 테스트 통과.
```bash
cd typescript && npm test -- --run
```
기대 결과: 모든 TypeScript 테스트 통과.

View file

@ -0,0 +1,154 @@
<!-- task=tls_support plan=0 tag=TLS -->
# Code Review Reference - TLS
## 개요
date=2026-04-25
task=tls_support, plan=0, tag=TLS
## 이 파일을 읽는 리뷰 에이전트에게
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
1. `CODE_REVIEW.md` → `code_review_0.log` (기존 code_review_*.log 수 = 0)
2. `PLAN.md` → `plan_0.log` (기존 plan_*.log 수 = 0)
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [TLS-1] Python TLS TCP (`connect_tcp_tls` + `TcpServer` ssl_context) | [x] |
| [TLS-2] Python WSS (`connect_wss` + `WsServer` ssl_context) | [x] |
| [TLS-3] TypeScript TLS TCP (`connectTcpTls` + `TcpServer` tlsOptions) | [x] |
| [TLS-4] TypeScript WSS (`connectWss` + `WsServer` tlsOptions) | [x] |
| [TLS-5] Kotlin TLS TCP (`dialTcpTls` + `TcpServer` sslContext) | [x] |
## 계획 대비 변경 사항
- Python은 `toki_socket.__init__`에도 `connect_tcp_tls`, `connect_wss`를 export했다. 기존 `connect_tcp`, `connect_ws` 공개 방식과 맞추기 위함이다.
- TypeScript WSS 테스트의 클라이언트 옵션은 `{ ca: cert }`만 사용했다. `ws`의 `ClientOptions` 타입에 `servername`이 없고, 테스트 인증서에 `IP:127.0.0.1`, `DNS:localhost` SAN이 있어 검증에 충분하다.
- Kotlin `TcpServer`는 기존 `TcpServer(host, port) { ... }` trailing lambda 호출 호환성을 지키기 위해 `sslContext`를 `newClient` 람다 앞에 둔다. TLS 서버 생성은 `TcpServer(host, port, sslContext) { ... }` 형태로 사용한다.
- Kotlin은 기존 `DialTcp` 패턴에 맞춰 `DialTcpTls` wrapper도 함께 추가했다.
## 주요 설계 결정
- 테스트 인증서는 계획대로 `dart/test/certs/server.crt`, `server.key`를 각 언어 테스트 리소스 위치에 복사했다.
- Python/TypeScript 서버 TLS 설정은 선택 인자로 추가해 기존 non-TLS 호출 경로를 유지했다.
- TypeScript WSS 서버는 TLS 모드에서 `https.Server`를 보관하고 `stop()`에서 `WebSocketServer`와 함께 닫는다.
- Kotlin TLS 테스트는 `PKCS#8` PEM key와 X.509 cert로 테스트용 server/client `SSLContext`를 생성한다.
## 리뷰어를 위한 체크포인트
- `python/toki_socket/tcp_client.py` — `connect_tcp_tls()` 함수 존재, `asyncio.open_connection(ssl=...)` 사용 여부
- `python/toki_socket/tcp_server.py` — `__init__` `ssl_context` 파라미터 및 `start()`에서 전달 여부
- `python/toki_socket/ws_client.py` — `connect_wss()` 함수 존재, `ws_connect(uri, ssl=...)` 사용 여부
- `python/toki_socket/ws_server.py` — `__init__` `ssl_context` 파라미터 및 `ws_serve()` 두 경로에 전달 여부
- `python/test/certs/` — cert 파일 존재 여부
- `typescript/src/tcp_client.ts` — `connectTcpTls()` 함수 존재, `tls.connect()` + `secureConnect` 이벤트 사용 여부
- `typescript/src/tcp_server.ts` — `tlsOptions` 파라미터, `tls.createServer()` 분기 여부
- `typescript/src/ws_client.ts` — `connectWss()` 함수 존재 여부
- `typescript/src/ws_server.ts` — `tlsOptions` 파라미터, `https.createServer()` + `httpsServer.close()` 포함 여부
- `typescript/test/certs/` — cert 파일 존재 여부
- `kotlin/src/main/kotlin/.../TcpClient.kt` — `dialTcpTls()` 함수 존재 여부
- `kotlin/src/main/kotlin/.../TcpServer.kt` — `sslContext` 파라미터, TLS 분기, `port()` getter 여부
- `kotlin/src/test/resources/` — cert 파일 존재 여부
- 기존 테스트 회귀 없음 (Python 19개, TypeScript 27개, Kotlin 전체 PASS)
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### TLS-1 중간 검증
```
$ cd python && python3 -m py_compile toki_socket/tcp_client.py toki_socket/tcp_server.py
(no output, exit 0)
$ cd python && python3 -m pytest test/test_tcp.py -q
...... [100%]
6 passed in 0.37s
```
### TLS-2 중간 검증
```
$ cd python && python3 -m py_compile toki_socket/ws_client.py toki_socket/ws_server.py
(no output, exit 0)
$ cd python && python3 -m pytest test/test_ws.py -q
...... [100%]
6 passed in 0.36s
```
### TLS-3 중간 검증
```
$ cd typescript && npx tsc --noEmit
(no output, exit 0)
$ cd typescript && npx vitest run test/tcp.test.ts
RUN v3.2.4 /config/workspace/toki_socket/typescript
✓ test/tcp.test.ts (5 tests) 85ms
Test Files 1 passed (1)
Tests 5 passed (5)
Start at 05:18:33
Duration 1.93s (transform 416ms, setup 0ms, collect 598ms, tests 85ms, environment 0ms, prepare 331ms)
```
### TLS-4 중간 검증
```
$ cd typescript && npx tsc --noEmit
(no output, exit 0)
$ cd typescript && npx vitest run test/ws.test.ts
RUN v3.2.4 /config/workspace/toki_socket/typescript
✓ test/ws.test.ts (5 tests) 85ms
Test Files 1 passed (1)
Tests 5 passed (5)
Start at 05:18:54
Duration 1.45s (transform 246ms, setup 0ms, collect 524ms, tests 85ms, environment 0ms, prepare 381ms)
```
### TLS-5 중간 검증
```
$ cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle ./gradlew test --tests "*.TlsTcpTest"
> Task :test
BUILD SUCCESSFUL in 10s
11 actionable tasks: 3 executed, 8 up-to-date
```
### 최종 검증
```
$ cd python && python3 -m py_compile toki_socket/tcp_client.py toki_socket/tcp_server.py toki_socket/ws_client.py toki_socket/ws_server.py
(no output, exit 0)
$ cd python && python3 -m pytest test/ -q
................... [100%]
19 passed in 0.39s
$ cd typescript && npx tsc --noEmit && npx vitest run
RUN v3.2.4 /config/workspace/toki_socket/typescript
✓ test/communicator.test.ts (10 tests) 30ms
✓ test/base_client.test.ts (7 tests) 65ms
✓ test/tcp.test.ts (5 tests) 72ms
✓ test/ws.test.ts (5 tests) 68ms
Test Files 4 passed (4)
Tests 27 passed (27)
Start at 05:22:53
Duration 1.37s (transform 772ms, setup 0ms, collect 1.77s, tests 235ms, environment 1ms, prepare 1.31s)
$ cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle ./gradlew test
> Task :test
BUILD SUCCESSFUL in 14s
11 actionable tasks: 1 executed, 10 up-to-date
```

View file

@ -0,0 +1,24 @@
task=tls_support plan=0 tag=TLS
date=2026-04-26
result=PASS
## 완료 항목
- [TLS-1] Python TLS TCP: connect_tcp_tls() + TcpServer ssl_context 파라미터
- [TLS-2] Python WSS: connect_wss() + WsServer ssl_context 파라미터
- [TLS-3] TypeScript TLS TCP: connectTcpTls() + TcpServer tlsOptions 파라미터
- [TLS-4] TypeScript WSS: connectWss() + WsServer tlsOptions(https.Server) 파라미터
- [TLS-5] Kotlin TLS TCP: dialTcpTls() + DialTcpTls() + TcpServer sslContext 파라미터
## 계획 대비 변경 사항
- Python __init__에 connect_tcp_tls, connect_wss export 추가
- TypeScript WSS 클라이언트 옵션은 { ca: cert }만 사용 (servername 불필요)
- Kotlin TcpServer 생성자에서 sslContext를 newClient 람다 앞에 배치 (trailing lambda 호환)
- Kotlin DialTcpTls wrapper 추가
## 검증
- python pytest: 19/19 PASS
- typescript vitest: 27/27 PASS
- kotlin gradle test: BUILD SUCCESSFUL

View file

@ -0,0 +1,598 @@
<!-- task=tls_support plan=0 tag=TLS -->
# Python·TypeScript·Kotlin TLS 지원 추가
## 이 파일을 읽는 구현 에이전트에게
각 항목의 체크리스트를 완료 표시하고, 중간 검증 명령을 실행한 뒤 출력을 CODE_REVIEW.md의 `검증 결과` 섹션에 붙여 넣는다. 최종 검증까지 완료한 후 CODE_REVIEW.md의 각 항목을 `[x]`로 체크한다. **의존 관계 및 구현 순서** 섹션에 따라 항목 순서를 지킨다.
## 배경
Go와 Dart는 TLS TCP와 WSS를 모두 지원하며 crosstest에서 검증된다. Python과 TypeScript는 구현 완료(`Available`) 상태이지만 TLS 코드가 전혀 없다. Kotlin은 WSS 클라이언트(`dialWss`)만 지원하고 TCP TLS가 없다. 이번 작업으로 세 언어에 TLS 지원을 추가해 Go/Dart와 동등한 수준의 TLS 커버리지를 갖춘다.
**테스트 인증서 전략:** `dart/test/certs/server.crt`(CN=localhost, SAN IP:127.0.0.1)와 `server.key`(PKCS#8 RSA)가 이미 존재한다. 각 언어의 테스트 디렉토리에 복사해 사용한다.
---
## [TLS-1] Python TLS TCP
### 문제
`python/toki_socket/tcp_client.py` — TLS 연결 함수 없음.
`python/toki_socket/tcp_server.py` — `TcpServer.__init__`와 `start()`에 `ssl` 파라미터 없음.
### 해결 방법
**`tcp_client.py`:** `connect_tcp_tls()` 함수 추가. `asyncio.open_connection(ssl=ssl_context)` 사용.
```python
# 추가 import
import ssl
async def connect_tcp_tls(
host: str,
port: int,
ssl_context: ssl.SSLContext,
interval_sec: int,
wait_sec: int,
parser_map: ParserMap,
) -> TcpClient:
reader, writer = await asyncio.open_connection(host, port, ssl=ssl_context)
return TcpClient(reader, writer, interval_sec, wait_sec, parser_map)
```
**`tcp_server.py`:** `TcpServer.__init__`에 `ssl_context: ssl.SSLContext | None = None` 파라미터 추가, `start()`에서 `asyncio.start_server(ssl=self._ssl_context)` 전달.
**Before (`tcp_server.py:13-18`):**
```python
class TcpServer:
def __init__(
self,
host: str,
port: int,
new_client: Callable[[asyncio.StreamReader, asyncio.StreamWriter], TcpClient] | None = None,
interval_sec: int = 0,
wait_sec: int = 0,
parser_map: ParserMap | None = None,
) -> None:
```
**After:**
```python
class TcpServer:
def __init__(
self,
host: str,
port: int,
new_client: Callable[[asyncio.StreamReader, asyncio.StreamWriter], TcpClient] | None = None,
interval_sec: int = 0,
wait_sec: int = 0,
parser_map: ParserMap | None = None,
ssl_context: ssl.SSLContext | None = None,
) -> None:
```
그리고 `__init__` 바디에 `self._ssl_context = ssl_context` 추가.
**Before (`tcp_server.py` `start()`):**
```python
async def start(self) -> None:
self._server = await asyncio.start_server(self._handle_connection, self._host, self._port)
```
**After:**
```python
async def start(self) -> None:
self._server = await asyncio.start_server(
self._handle_connection, self._host, self._port, ssl=self._ssl_context
)
```
### 수정 파일 및 체크리스트
- `python/toki_socket/tcp_client.py`
- [x] `import ssl` 추가
- [x] `connect_tcp_tls()` 함수 추가
- `python/toki_socket/tcp_server.py`
- [x] `import ssl` 추가
- [x] `__init__` 파라미터에 `ssl_context: ssl.SSLContext | None = None` 추가
- [x] `__init__` 바디에 `self._ssl_context = ssl_context` 추가
- [x] `start()` 바디에 `ssl=self._ssl_context` 전달
### 테스트 작성
파일: `python/test/test_tcp.py`
- 테스트 이름: `test_tcp_tls_send_receive`, `test_tcp_tls_request_response`
- 인증서: `python/test/certs/server.crt`, `python/test/certs/server.key`를 복사 (dart/test/certs/ 에서)
```python
def _make_server_ssl_context() -> ssl.SSLContext:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
certs_dir = Path(__file__).parent / "certs"
ctx.load_cert_chain(certs_dir / "server.crt", certs_dir / "server.key")
return ctx
def _make_client_ssl_context() -> ssl.SSLContext:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
certs_dir = Path(__file__).parent / "certs"
ctx.load_verify_locations(certs_dir / "server.crt")
ctx.check_hostname = False
return ctx
```
### 중간 검증
```
$ cd python && python3 -m py_compile toki_socket/tcp_client.py toki_socket/tcp_server.py
(exit 0)
$ cd python && python3 -m pytest test/test_tcp.py -q
(전체 PASS)
```
---
## [TLS-2] Python WSS
### 문제
`python/toki_socket/ws_client.py` — WSS 연결 함수 없음.
`python/toki_socket/ws_server.py` — `WsServer.__init__`와 `start()`에 `ssl` 파라미터 없음.
### 해결 방법
**`ws_client.py`:** `connect_wss()` 함수 추가. `ws_connect("wss://...", ssl=ssl_context)`.
```python
import ssl
async def connect_wss(
host: str,
port: int,
path: str,
ssl_context: ssl.SSLContext,
interval_sec: int,
wait_sec: int,
parser_map: ParserMap,
) -> WsClient:
uri = f"wss://{host}:{port}{path}"
ws = await ws_connect(uri, ssl=ssl_context)
return WsClient(ws, interval_sec, wait_sec, parser_map)
```
**`ws_server.py`:** `WsServer.__init__`에 `ssl_context: ssl.SSLContext | None = None` 파라미터 추가, `start()`에서 `ws_serve(handler, ssl=self._ssl_context)` 전달.
기존 `start()`의 `await ws_serve(handler, self._host, self._port)` 호출을 `await ws_serve(handler, self._host, self._port, ssl=self._ssl_context)`로 변경. 두 API 경로(`_NEW_WEBSOCKETS_API`, legacy) 모두 변경.
### 수정 파일 및 체크리스트
- `python/toki_socket/ws_client.py`
- [x] `import ssl` 추가
- [x] `connect_wss()` 함수 추가
- `python/toki_socket/ws_server.py`
- [x] `import ssl` 추가
- [x] `__init__` 파라미터에 `ssl_context: ssl.SSLContext | None = None` 추가
- [x] `__init__` 바디에 `self._ssl_context = ssl_context` 추가
- [x] `start()` — `ws_serve()` 호출에 `ssl=self._ssl_context` 추가 (new API + legacy API 두 경로 모두)
### 테스트 작성
파일: `python/test/test_ws.py`
- 테스트 이름: `test_ws_tls_send_receive`, `test_ws_tls_request_response`
- TLS-1에서 만든 헬퍼 함수 재사용 (같은 certs 디렉토리)
### 중간 검증
```
$ cd python && python3 -m py_compile toki_socket/ws_client.py toki_socket/ws_server.py
(exit 0)
$ cd python && python3 -m pytest test/test_ws.py -q
(전체 PASS)
```
---
## [TLS-3] TypeScript TLS TCP
### 문제
`typescript/src/tcp_client.ts` — TLS 연결 함수 없음.
`typescript/src/tcp_server.ts` — `TcpServer`에 TLS 옵션 없음.
### 해결 방법
**`tcp_client.ts`:** `connectTcpTls()` 추가. `tls.TLSSocket extends net.Socket`이므로 `TcpClient` 생성자에 직접 전달 가능.
```typescript
import * as tls from "node:tls";
export async function connectTcpTls(
host: string,
port: number,
tlsOptions: tls.ConnectionOptions,
intervalSec: number,
waitSec: number,
parserMap: ParserMap,
): Promise<TcpClient> {
return new Promise<TcpClient>((resolve, reject) => {
const socket = tls.connect({ ...tlsOptions, host, port });
const onSecureConnect = () => {
cleanup();
resolve(new TcpClient(socket, intervalSec, waitSec, parserMap));
};
const onError = (err: Error) => {
cleanup();
reject(err);
};
const cleanup = () => {
socket.off("secureConnect", onSecureConnect);
socket.off("error", onError);
};
socket.once("secureConnect", onSecureConnect);
socket.once("error", onError);
});
}
```
**`tcp_server.ts`:** `TcpServer` 생성자에 선택적 `tlsOptions?: tls.TlsOptions` 파라미터 추가. `server` 필드 타입을 `net.Server | tls.Server`로 변경하고, 생성자에서 `tlsOptions` 유무로 분기.
**Before (`tcp_server.ts`):**
```typescript
import * as net from "node:net";
// ...
export class TcpServer {
private readonly server: net.Server;
// ...
constructor(
private readonly host: string,
private readonly listenPort: number,
private readonly newClient: (socket: net.Socket) => TcpClient,
) {
this.server = net.createServer((socket) => {
const client = this.newClient(socket);
// ...
});
}
```
**After:**
```typescript
import * as net from "node:net";
import * as tls from "node:tls";
// ...
export class TcpServer {
private readonly server: net.Server;
// ...
constructor(
private readonly host: string,
private readonly listenPort: number,
private readonly newClient: (socket: net.Socket) => TcpClient,
tlsOptions?: tls.TlsOptions,
) {
const handleSocket = (socket: net.Socket): void => {
const client = this.newClient(socket);
this.clients.add(client);
client.addDisconnectListener(() => { this.removeClient(client); });
this.onClientConnected(client);
};
this.server = tlsOptions
? tls.createServer(tlsOptions, handleSocket)
: net.createServer(handleSocket);
}
```
기존 `net.createServer` 콜백 바디를 `handleSocket`으로 추출해서 중복 없이 두 경로에 사용한다.
**`index.ts`:** `connectTcpTls` export 추가.
### 수정 파일 및 체크리스트
- `typescript/src/tcp_client.ts`
- [x] `import * as tls from "node:tls"` 추가
- [x] `connectTcpTls()` 함수 추가
- `typescript/src/tcp_server.ts`
- [x] `import * as tls from "node:tls"` 추가
- [x] `server` 필드 타입 `net.Server | tls.Server`로 변경
- [x] 생성자에 `tlsOptions?: tls.TlsOptions` 파라미터 추가
- [x] 생성자 바디: `handleSocket` 헬퍼 추출 후 분기 처리
- `typescript/src/index.ts`
- [x] `connectTcpTls` 포함 (tcp_client.ts re-export로 자동 포함되는지 확인)
### 테스트 작성
파일: `typescript/test/tcp.test.ts`
- 테스트 이름: `"connectTcpTls sends and receives TestData"`, `"TLS sendRequest/response roundtrip"`
- 인증서: `typescript/test/certs/server.crt`, `server.key`(dart/test/certs/ 에서 복사)
- 헬퍼:
```typescript
import * as fs from "node:fs";
import * as path from "node:path";
const certsDir = path.join(import.meta.dirname, "certs");
const cert = fs.readFileSync(path.join(certsDir, "server.crt"));
const key = fs.readFileSync(path.join(certsDir, "server.key"));
```
- `TcpServer` TLS 옵션: `{ cert, key }`
- `connectTcpTls` 옵션: `{ ca: cert, servername: "localhost" }`
### 중간 검증
```
$ cd typescript && npx tsc --noEmit
(exit 0)
$ cd typescript && npx vitest run test/tcp.test.ts
(전체 PASS)
```
---
## [TLS-4] TypeScript WSS
### 문제
`typescript/src/ws_client.ts` — WSS 연결 함수 없음.
`typescript/src/ws_server.ts` — `WsServer`에 TLS 옵션 없음.
### 해결 방법
**`ws_client.ts`:** `connectWss()` 추가. `WebSocket.ClientOptions`를 사용해 CA 인증서 등을 전달.
```typescript
export async function connectWss(
host: string,
port: number,
path: string,
wsOptions: WebSocket.ClientOptions,
intervalSec: number,
waitSec: number,
parserMap: ParserMap,
): Promise<WsClient> {
return new Promise<WsClient>((resolve, reject) => {
const ws = new WebSocket(`wss://${host}:${port}${path}`, wsOptions);
const onOpen = () => { cleanup(); resolve(new WsClient(ws, intervalSec, waitSec, parserMap)); };
const onError = (err: Error) => { cleanup(); reject(err); };
const cleanup = () => { ws.off("open", onOpen); ws.off("error", onError); };
ws.once("open", onOpen);
ws.once("error", onError);
});
}
```
**`ws_server.ts`:** `WsServer`에 `tlsOptions?: https.ServerOptions` 파라미터 추가. `tlsOptions` 유무로 `ws` vs `wss` 경로를 분기. `httpsServer`를 인스턴스 필드로 보관해 `stop()`에서 함께 종료.
**추가 import:**
```typescript
import * as https from "node:https";
```
**생성자 시그니처:**
```typescript
constructor(
private readonly host: string,
private readonly listenPort: number,
private readonly path: string,
private readonly newClient: (ws: WebSocket) => WsClient,
private readonly tlsOptions?: https.ServerOptions,
) {}
```
**`start()` 변경 포인트:**
- `tlsOptions`가 있으면: `httpsServer = https.createServer(this.tlsOptions)`, `wss = new WebSocketServer({ server: httpsServer, path })`, `httpsServer.listen(port, host)` 후 `httpsServer`의 `listening` 이벤트 대기
- 없으면: 기존 `WebSocketServer({ host, port, path })` 경로 유지
**`stop()` 변경 포인트:**
- TLS 모드: `await closeServer(wss)` 후 `httpsServer.close()`도 닫는다
- plain 모드: 변경 없음
**`index.ts`:** `connectWss` re-export 확인 (ws_client.ts export가 자동 포함되는지 확인).
### 수정 파일 및 체크리스트
- `typescript/src/ws_client.ts`
- [x] `connectWss()` 함수 추가
- `typescript/src/ws_server.ts`
- [x] `import * as https from "node:https"` 추가
- [x] `httpsServer: https.Server | null = null` 인스턴스 필드 추가
- [x] 생성자에 `tlsOptions?: https.ServerOptions` 파라미터 추가
- [x] `start()` 분기 처리 (TLS vs plain)
- [x] `stop()` — TLS 모드 시 `httpsServer.close()` 호출 추가
### 테스트 작성
파일: `typescript/test/ws.test.ts`
- 테스트 이름: `"connectWss sends and receives TestData"`, `"WSS sendRequest/response roundtrip"`
- TLS-3에서 만든 `cert`, `key` 재사용
- `WsServer` TLS 옵션: `{ cert, key }`
- `connectWss` 옵션: `{ ca: cert }` (rejectUnauthorized 기본값 true 유지)
### 중간 검증
```
$ cd typescript && npx tsc --noEmit
(exit 0)
$ cd typescript && npx vitest run test/ws.test.ts
(전체 PASS)
```
---
## [TLS-5] Kotlin TLS TCP
### 문제
`kotlin/src/main/kotlin/.../TcpClient.kt` — `dialTcpTls()` top-level 함수 없음.
`kotlin/src/main/kotlin/.../TcpServer.kt` — TLS `ServerSocket` 생성 경로 없음.
### 해결 방법
**`TcpClient.kt`:** `dialTcpTls()` top-level 함수 추가. `SSLContext.socketFactory.createSocket()` 사용.
```kotlin
import javax.net.ssl.SSLContext
fun dialTcpTls(
host: String,
port: Int,
sslContext: SSLContext,
intervalSec: Int = 30,
waitSec: Int = 10,
parserMap: ParserMap,
): TcpClient {
val socket = sslContext.socketFactory.createSocket(host, port)
return TcpClient.fromSocket(socket, intervalSec, waitSec, parserMap)
}
```
**`TcpServer.kt`:** 생성자에 `sslContext: SSLContext? = null` 파라미터 추가. `start()` 내 `ServerSocket()` 생성 부분을 분기.
**Before (`TcpServer.kt` `start()`):**
```kotlin
val server = ServerSocket()
server.reuseAddress = true
server.bind(InetSocketAddress(host, port))
serverSocket = server
```
**After:**
```kotlin
val server: ServerSocket = if (sslContext != null) {
sslContext.serverSocketFactory.createServerSocket()
} else {
ServerSocket()
}
server.reuseAddress = true
server.bind(InetSocketAddress(host, port))
serverSocket = server
```
`TcpServer` 생성자 파라미터 추가:
```kotlin
class TcpServer(
private val host: String,
private val port: Int,
private val newClient: (java.net.Socket) -> TcpClient,
private val sslContext: SSLContext? = null,
) {
```
**`port()` getter 추가** (테스트에서 port=0 사용 시 필요):
```kotlin
fun port(): Int = serverSocket?.localPort ?: port
```
### 수정 파일 및 체크리스트
- `kotlin/src/main/kotlin/com/tokilabs/toki_socket/TcpClient.kt`
- [x] `import javax.net.ssl.SSLContext` 추가 (중복 확인)
- [x] `dialTcpTls()` top-level 함수 추가
- `kotlin/src/main/kotlin/com/tokilabs/toki_socket/TcpServer.kt`
- [x] `import javax.net.ssl.SSLContext` 추가
- [x] 생성자 파라미터에 `sslContext: SSLContext? = null` 추가
- [x] `start()` 분기 처리 (TLS `SSLServerSocket` vs 일반 `ServerSocket`)
- [x] `fun port(): Int` getter 추가 (필요 시)
### 테스트 작성
파일: `kotlin/src/test/kotlin/com/tokilabs/toki_socket/TlsTcpTest.kt` (신규)
- 인증서: `kotlin/src/test/resources/server.crt`, `server.key` (dart/test/certs/ 에서 복사)
- `TestHelpers.kt`에 `createTestSslContexts(certPath, keyPath): Pair<SSLContext, SSLContext>` 추가:
```kotlin
fun createTestSslContexts(certPath: String, keyPath: String): Pair<SSLContext, SSLContext> {
val certFactory = java.security.cert.CertificateFactory.getInstance("X.509")
val cert = java.io.File(certPath).inputStream().use {
certFactory.generateCertificate(it) as java.security.cert.X509Certificate
}
val keyPem = java.io.File(keyPath).readText()
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replace("\\s+".toRegex(), "")
val keyBytes = java.util.Base64.getDecoder().decode(keyPem)
val privateKey = java.security.KeyFactory.getInstance("RSA")
.generatePrivate(java.security.spec.PKCS8EncodedKeySpec(keyBytes))
val ks = java.security.KeyStore.getInstance("PKCS12")
ks.load(null, null)
ks.setKeyEntry("toki", privateKey, CharArray(0), arrayOf(cert))
val kmf = javax.net.ssl.KeyManagerFactory.getInstance(
javax.net.ssl.KeyManagerFactory.getDefaultAlgorithm()
)
kmf.init(ks, CharArray(0))
val serverCtx = SSLContext.getInstance("TLS")
serverCtx.init(kmf.keyManagers, null, null)
val ts = java.security.KeyStore.getInstance("PKCS12")
ts.load(null, null)
ts.setCertificateEntry("toki", cert)
val tmf = javax.net.ssl.TrustManagerFactory.getInstance(
javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()
)
tmf.init(ts)
val clientCtx = SSLContext.getInstance("TLS")
clientCtx.init(null, tmf.trustManagers, null)
return Pair(serverCtx, clientCtx)
}
```
- 테스트: `testTlsTcpSendReceive`, `testTlsTcpRequestResponse`
- 인증서 경로 로드: `Thread.currentThread().contextClassLoader.getResource("server.crt")!!.path`
### 중간 검증
```
$ cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle ./gradlew test --tests "*.TlsTcpTest"
BUILD SUCCESSFUL
```
---
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `python/toki_socket/tcp_client.py` | TLS-1 |
| `python/toki_socket/tcp_server.py` | TLS-1 |
| `python/toki_socket/ws_client.py` | TLS-2 |
| `python/toki_socket/ws_server.py` | TLS-2 |
| `python/test/test_tcp.py` | TLS-1 |
| `python/test/test_ws.py` | TLS-2 |
| `python/test/certs/server.crt` | TLS-1, TLS-2 (신규, 복사) |
| `python/test/certs/server.key` | TLS-1, TLS-2 (신규, 복사) |
| `typescript/src/tcp_client.ts` | TLS-3 |
| `typescript/src/tcp_server.ts` | TLS-3 |
| `typescript/src/ws_client.ts` | TLS-4 |
| `typescript/src/ws_server.ts` | TLS-4 |
| `typescript/test/tcp.test.ts` | TLS-3 |
| `typescript/test/ws.test.ts` | TLS-4 |
| `typescript/test/certs/server.crt` | TLS-3, TLS-4 (신규, 복사) |
| `typescript/test/certs/server.key` | TLS-3, TLS-4 (신규, 복사) |
| `kotlin/src/main/kotlin/.../TcpClient.kt` | TLS-5 |
| `kotlin/src/main/kotlin/.../TcpServer.kt` | TLS-5 |
| `kotlin/src/test/kotlin/.../TlsTcpTest.kt` | TLS-5 (신규) |
| `kotlin/src/test/kotlin/.../TestHelpers.kt` | TLS-5 |
| `kotlin/src/test/resources/server.crt` | TLS-5 (신규, 복사) |
| `kotlin/src/test/resources/server.key` | TLS-5 (신규, 복사) |
## 의존 관계 및 구현 순서
- TLS-1 → TLS-2 (Python 소스 파일 순서)
- TLS-3 → TLS-4 (TypeScript 소스 파일 순서)
- TLS-5 독립 (Kotlin)
- TLS-1/TLS-2와 TLS-3/TLS-4는 병렬 가능
## 최종 검증
```
$ cd python && python3 -m py_compile toki_socket/tcp_client.py toki_socket/tcp_server.py toki_socket/ws_client.py toki_socket/ws_server.py
(exit 0)
$ cd python && python3 -m pytest test/ -q
(전체 PASS)
$ cd typescript && npx tsc --noEmit
(exit 0)
$ cd typescript && npx vitest run
(전체 PASS)
$ cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle ./gradlew test
BUILD SUCCESSFUL
```

View file

@ -7,6 +7,8 @@ import 'packets/message_common.pb.dart';
import 'transport.dart';
abstract class Communicator {
static const int maxNonce = 2147483647;
Map<String, IDataHandler> _handlerDic = {};
Map<String, Future<void> Function(List<int>, int)> _requestHandlerDic = {};
Map<int, _PendingRequest> _pendingRequests = {};
@ -21,6 +23,15 @@ abstract class Communicator {
@protected
set nonce(int value) => _nonce = value;
@protected
int nextNonce() {
if (_nonce >= maxNonce) {
_nonce = 0;
}
_nonce += 1;
return _nonce;
}
/// Whether the connection is alive. Set by each transport implementation.
bool _isAlive = false;
bool get isAlive => _isAlive;
@ -32,9 +43,9 @@ abstract class Communicator {
void initialize(
Map<String, GeneratedMessage Function(List<int>)> instanceGenerator,
{Transport? transport}) {
{required Transport transport}) {
_instanceGenerator = instanceGenerator;
_transport = transport ?? _LegacyCommunicatorTransport(this);
_transport = transport;
}
T Function(List<int>) getGenerator<T extends GeneratedMessage>(String type) {
@ -45,14 +56,6 @@ abstract class Communicator {
return _instanceGenerator[type] as T Function(List<int>);
}
/// Deprecated compatibility path for subclasses that still override writes.
///
/// New clients should pass a [Transport] to [initialize] instead.
@Deprecated('Pass a Transport to initialize instead.')
Future<void> transmitPacket(PacketBase base) {
return Future.error(StateError('transport is not initialized'));
}
/// Serializes writes so stream transports do not interleave packets.
Future<void> queuePacket(PacketBase base) {
final write = _outboundWrite.then((_) => _transport!.writePacket(base));
@ -60,7 +63,14 @@ abstract class Communicator {
return write;
}
Future<void> send<T extends GeneratedMessage>(T data);
Future<void> send<T extends GeneratedMessage>(T data) async {
if (isAlive) {
await queuePacket(PacketBase()
..typeName = data.info_.qualifiedMessageName
..nonce = nextNonce()
..data = data.writeToBuffer());
}
}
@protected
void cancelPendingRequests() {
@ -85,7 +95,7 @@ abstract class Communicator {
Req data,
{Duration timeout = const Duration(seconds: 30)}) async {
if (!isAlive) return Future.error(StateError('not connected'));
final requestNonce = ++nonce;
final requestNonce = nextNonce();
final completer = Completer<Res>();
final expectedResponseType = Res.toString();
_pendingRequests[requestNonce] = _PendingRequest(
@ -135,7 +145,7 @@ abstract class Communicator {
if (isAlive) {
await queuePacket(PacketBase()
..typeName = res.info_.qualifiedMessageName
..nonce = ++nonce
..nonce = nextNonce()
..responseNonce = requestNonce
..data = res.writeToBuffer());
}
@ -189,20 +199,6 @@ abstract class Communicator {
}
}
class _LegacyCommunicatorTransport implements Transport {
final Communicator _communicator;
_LegacyCommunicatorTransport(this._communicator);
@override
Future<void> writePacket(PacketBase base) {
return _communicator.transmitPacket(base);
}
@override
Future<void> close() async {}
}
abstract class IDataHandler {
void onMessage(List<int> data);
}

View file

@ -50,7 +50,7 @@ mixin HeartbeatMixin on Communicator {
try {
await queuePacket(PacketBase()
..typeName = data.info_.qualifiedMessageName
..nonce = ++nonce
..nonce = nextNonce()
..data = data.writeToBuffer());
} catch (e) {
close();

View file

@ -1,6 +1,6 @@
name: toki_socket
description: Multi-language standard socket protocol library. Dart implementation.
version: 0.1.0
version: 1.0.5
publish_to: none
environment:

View file

@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:mirrors';
import 'package:protobuf/protobuf.dart';
import 'package:test/test.dart';
import 'package:toki_socket/toki_socket.dart';
@ -21,15 +20,8 @@ class _FakeCommunicator extends Communicator {
cancelPendingRequests();
}
@override
Future<void> send<T extends GeneratedMessage>(T data) async {
if (isAlive) {
await queuePacket(PacketBase()
..typeName = data.info_.qualifiedMessageName
..nonce = ++nonce
..data = data.writeToBuffer());
}
return Future.value();
void setNonceForTest(int value) {
nonce = value;
}
}
@ -125,6 +117,66 @@ void main() {
expect(_pendingRequestsOf(communicator), isEmpty);
});
test('nonce wraps after int32 max without emitting zero', () async {
final communicator = _FakeCommunicator();
communicator.setNonceForTest(Communicator.maxNonce - 1);
await communicator.send(TestData()..index = 1);
await communicator.send(TestData()..index = 2);
expect(
communicator.transport.sentPackets.map((packet) => packet.nonce),
[Communicator.maxNonce, 1],
);
expect(
communicator.transport.sentPackets.map((packet) => packet.nonce),
isNot(contains(0)),
);
});
test('sendRequest matches response at nonce wrap boundary', () async {
final communicator = _FakeCommunicator();
communicator.setNonceForTest(Communicator.maxNonce - 1);
final maxNonceFuture = communicator.sendRequest<TestData, TestData>(
TestData()
..index = 1
..message = 'max',
);
await Future<void>.delayed(Duration.zero);
final maxNonceRequest = communicator.transport.sentPackets.single;
expect(maxNonceRequest.nonce, Communicator.maxNonce);
expect(maxNonceRequest.nonce, isNot(0));
communicator.onReceivedData(
TestData.getDefault().info_.qualifiedMessageName,
(TestData()
..index = 2
..message = 'max response')
.writeToBuffer(),
responseNonce: maxNonceRequest.nonce,
);
expect((await maxNonceFuture).message, 'max response');
final wrappedFuture = communicator.sendRequest<TestData, TestData>(
TestData()
..index = 3
..message = 'wrapped',
);
await Future<void>.delayed(Duration.zero);
final wrappedRequest = communicator.transport.sentPackets.last;
expect(wrappedRequest.nonce, 1);
expect(wrappedRequest.nonce, isNot(0));
communicator.onReceivedData(
TestData.getDefault().info_.qualifiedMessageName,
(TestData()
..index = 4
..message = 'wrapped response')
.writeToBuffer(),
responseNonce: wrappedRequest.nonce,
);
expect((await wrappedFuture).message, 'wrapped response');
});
test(
'cannot register addRequestListener for a type already using addListener',
() {

View file

@ -15,6 +15,8 @@ import (
var ErrNotConnected = errors.New("not connected")
const MaxNonce int32 = 1<<31 - 1
type ParserMap map[string]func([]byte) (proto.Message, error)
type Transport interface {
@ -88,7 +90,18 @@ func (c *Communicator) SetWriteErrorHandler(fn func(error)) {
}
func (c *Communicator) nextNonce() int32 {
return c.nonce.Add(1)
for {
current := c.nonce.Load()
var next int32
if current >= MaxNonce {
next = 1
} else {
next = current + 1
}
if c.nonce.CompareAndSwap(current, next) {
return next
}
}
}
func (c *Communicator) shutdown() {

View file

@ -0,0 +1,131 @@
package toki_socket
import (
"sync"
"testing"
"time"
"google.golang.org/protobuf/proto"
"toki-labs.com/toki_socket/go/packets"
)
type nonceTestTransport struct {
mu sync.Mutex
packets []*packets.PacketBase
}
func (t *nonceTestTransport) WritePacket(base *packets.PacketBase) error {
t.mu.Lock()
defer t.mu.Unlock()
t.packets = append(t.packets, base)
return nil
}
func (t *nonceTestTransport) Close() error {
return nil
}
func (t *nonceTestTransport) sent() []*packets.PacketBase {
t.mu.Lock()
defer t.mu.Unlock()
return append([]*packets.PacketBase{}, t.packets...)
}
func nonceTestParserMap() ParserMap {
return ParserMap{
TypeNameOf(&packets.TestData{}): func(b []byte) (proto.Message, error) {
m := &packets.TestData{}
return m, proto.Unmarshal(b, m)
},
}
}
func TestNonceWrapsAfterInt32MaxWithoutEmittingZero(t *testing.T) {
transport := &nonceTestTransport{}
communicator := NewCommunicator(transport, nonceTestParserMap())
defer communicator.Close()
communicator.nonce.Store(MaxNonce - 1)
if err := communicator.Send(&packets.TestData{Index: 1}); err != nil {
t.Fatal(err)
}
if err := communicator.Send(&packets.TestData{Index: 2}); err != nil {
t.Fatal(err)
}
sent := transport.sent()
if len(sent) != 2 {
t.Fatalf("expected 2 packets, got %d", len(sent))
}
if got := sent[0].GetNonce(); got != MaxNonce {
t.Fatalf("first nonce = %d, want %d", got, MaxNonce)
}
if got := sent[1].GetNonce(); got != 1 {
t.Fatalf("second nonce = %d, want 1", got)
}
for _, packet := range sent {
if packet.GetNonce() == 0 {
t.Fatal("emitted reserved nonce 0")
}
}
}
func TestSendRequestNonceWrapsAtInt32Max(t *testing.T) {
transport := &nonceTestTransport{}
communicator := NewCommunicator(transport, nonceTestParserMap())
defer communicator.Close()
communicator.nonce.Store(MaxNonce - 1)
resultCh := make(chan *packets.TestData, 1)
errCh := make(chan error, 1)
go func() {
res, err := SendRequestTyped[*packets.TestData, *packets.TestData](
communicator,
&packets.TestData{Index: 1, Message: "max"},
time.Second,
)
if err != nil {
errCh <- err
return
}
resultCh <- res
}()
var request *packets.PacketBase
for deadline := time.Now().Add(time.Second); time.Now().Before(deadline); {
sent := transport.sent()
if len(sent) == 1 {
request = sent[0]
break
}
time.Sleep(time.Millisecond)
}
if request == nil {
t.Fatal("request packet was not sent")
}
if request.GetNonce() != MaxNonce {
t.Fatalf("request nonce = %d, want %d", request.GetNonce(), MaxNonce)
}
if request.GetNonce() == 0 {
t.Fatal("emitted reserved nonce 0")
}
response := &packets.TestData{Index: 2, Message: "max response"}
data, err := proto.Marshal(response)
if err != nil {
t.Fatal(err)
}
communicator.OnReceivedData(TypeNameOf(response), data, 0, request.GetNonce())
select {
case err := <-errCh:
t.Fatal(err)
case got := <-resultCh:
if got.GetMessage() != "max response" {
t.Fatalf("response message = %q, want %q", got.GetMessage(), "max response")
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for response")
}
}

View file

@ -5,7 +5,7 @@ plugins {
}
group = "com.tokilabs"
version = "0.1.0"
version = "1.0.5"
kotlin {
jvmToolchain(17)

View file

@ -31,6 +31,8 @@ typealias ParserMap = Map<String, (ByteArray) -> MessageLite>
private typealias RequestHandler = suspend (MessageLite, Int) -> Unit
internal const val MAX_NONCE: Int = Int.MAX_VALUE
private data class PendingRequest(
val expectedTypeName: String,
val ch: Channel<MessageLite> = Channel(capacity = 1),
@ -90,7 +92,13 @@ class Communicator(
}
@PublishedApi
internal fun nextNonce(): Int = nonce.incrementAndGet()
internal fun nextNonce(): Int {
while (true) {
val current = nonce.get()
val next = if (current >= MAX_NONCE) 1 else current + 1
if (nonce.compareAndSet(current, next)) return next
}
}
fun shutdown() {
if (!shutdownStarted.compareAndSet(false, true)) return

View file

@ -13,6 +13,7 @@ import java.io.IOException
import java.net.InetSocketAddress
import java.net.Socket
import java.nio.ByteBuffer
import javax.net.ssl.SSLContext
class TcpClient private constructor(
private val socket: Socket,
@ -111,6 +112,18 @@ fun dialTcp(
return TcpClient.fromSocket(socket, intervalSec, waitSec, parserMap)
}
fun dialTcpTls(
host: String,
port: Int,
sslContext: SSLContext,
intervalSec: Int = 30,
waitSec: Int = 10,
parserMap: ParserMap,
): TcpClient {
val socket = sslContext.socketFactory.createSocket(host, port)
return TcpClient.fromSocket(socket, intervalSec, waitSec, parserMap)
}
@Suppress("FunctionName")
fun DialTcp(
host: String,
@ -120,6 +133,16 @@ fun DialTcp(
parserMap: ParserMap,
): TcpClient = dialTcp(host, port, intervalSec, waitSec, parserMap)
@Suppress("FunctionName")
fun DialTcpTls(
host: String,
port: Int,
sslContext: SSLContext,
intervalSec: Int = 30,
waitSec: Int = 10,
parserMap: ParserMap,
): TcpClient = dialTcpTls(host, port, sslContext, intervalSec, waitSec, parserMap)
fun TcpClient.closeBlocking() {
close()
}

View file

@ -12,10 +12,12 @@ import java.net.ServerSocket
import java.net.SocketException
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
import javax.net.ssl.SSLContext
class TcpServer(
private val host: String,
private val port: Int,
private val sslContext: SSLContext? = null,
private val newClient: (java.net.Socket) -> TcpClient,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@ -27,11 +29,17 @@ class TcpServer(
fun started(): Boolean = started.get()
fun port(): Int = serverSocket?.localPort ?: port
fun clients(): List<TcpClient> = clients.toList()
fun start() {
if (!started.compareAndSet(false, true)) return
val server = ServerSocket()
val server: ServerSocket = if (sslContext != null) {
sslContext.serverSocketFactory.createServerSocket()
} else {
ServerSocket()
}
server.reuseAddress = true
server.bind(InetSocketAddress(host, port))
serverSocket = server

View file

@ -10,6 +10,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import java.util.Collections
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
@ -26,6 +27,12 @@ private class FakeTransport : Transport {
}
}
private fun Communicator.setNonceForTest(value: Int) {
val field = Communicator::class.java.getDeclaredField("nonce")
field.isAccessible = true
(field.get(this) as AtomicInteger).set(value)
}
class CommunicatorTest {
@Test
fun testSendRequestTimeout() = runTest {
@ -92,4 +99,47 @@ class CommunicatorTest {
assertEquals(typeNameOf<TestData>(), transport.packets.single().typeName)
communicator.close()
}
@Test
fun testNonceWrapsAfterIntMaxWithoutEmittingZero() = runTest {
val transport = FakeTransport()
val communicator = Communicator(transport, testParserMap(), CoroutineScope(SupervisorJob() + Dispatchers.Default))
communicator.setNonceForTest(Int.MAX_VALUE - 1)
communicator.send(TestData.newBuilder().setIndex(1).build())
communicator.send(TestData.newBuilder().setIndex(2).build())
assertEquals(listOf(Int.MAX_VALUE, 1), transport.packets.map { it.nonce })
assertTrue(transport.packets.none { it.nonce == 0 })
communicator.close()
}
@Test
fun testSendRequestNonceWrapsAtInt32Max() = runTest {
val transport = FakeTransport()
val communicator = Communicator(transport, testParserMap(), CoroutineScope(SupervisorJob() + Dispatchers.Default))
communicator.setNonceForTest(Int.MAX_VALUE - 1)
val result = async {
sendRequestTyped<TestData, TestData>(
communicator,
TestData.newBuilder().setIndex(1).setMessage("max").build(),
timeoutMs = 1_000,
)
}
while (transport.packets.isEmpty()) delay(1)
val request = transport.packets.single()
assertEquals(Int.MAX_VALUE, request.nonce)
assertTrue(request.nonce != 0)
val response = TestData.newBuilder().setIndex(2).setMessage("max response").build()
communicator.onReceivedData(
typeNameOf<TestData>(),
response.toByteArray(),
responseNonce = request.nonce,
)
assertEquals("max response", result.await().message)
communicator.close()
}
}

View file

@ -1,7 +1,17 @@
package com.tokilabs.toki_socket
import com.tokilabs.toki_socket.packets.TestData
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.net.ServerSocket
import java.util.Base64
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import kotlin.test.fail
fun testParserMap(): ParserMap =
@ -18,6 +28,37 @@ fun testData(): TestData =
fun freePort(): Int =
ServerSocket(0).use { it.localPort }
fun createTestSslContexts(certPath: String, keyPath: String): Pair<SSLContext, 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 keyBytes = Base64.getDecoder().decode(keyPem)
val privateKey = KeyFactory.getInstance("RSA")
.generatePrivate(PKCS8EncodedKeySpec(keyBytes))
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 serverContext = SSLContext.getInstance("TLS")
serverContext.init(keyManagerFactory.keyManagers, null, null)
val trustStore = KeyStore.getInstance("PKCS12")
trustStore.load(null, null)
trustStore.setCertificateEntry("toki", cert)
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
trustManagerFactory.init(trustStore)
val clientContext = SSLContext.getInstance("TLS")
clientContext.init(null, trustManagerFactory.trustManagers, null)
return Pair(serverContext, clientContext)
}
suspend fun waitForCondition(
timeoutMs: Long = 2_000L,
intervalMs: Long = 10L,

View file

@ -0,0 +1,75 @@
package com.tokilabs.toki_socket
import com.tokilabs.toki_socket.packets.TestData
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import java.nio.file.Paths
import kotlin.test.Test
import kotlin.test.assertEquals
class TlsTcpTest {
@Test
fun testTlsTcpSendReceive() = runBlocking {
val (serverContext, clientContext) = createTlsContexts()
val received = CompletableDeferred<TestData>()
val listenerReady = CompletableDeferred<Unit>()
val server = TcpServer(
"127.0.0.1",
0,
serverContext,
{ socket -> TcpClient.fromSocket(socket, 0, 0, testParserMap()) },
)
server.onClientConnected = { client ->
addListenerTyped<TestData>(client.communicator) { received.complete(it) }
listenerReady.complete(Unit)
}
server.start()
val client = dialTcpTls("127.0.0.1", server.port(), clientContext, 0, 0, testParserMap())
listenerReady.await()
client.send(TestData.newBuilder().setIndex(43).setMessage("hello tls").build())
assertEquals(43, received.await().index)
client.close()
server.stop()
}
@Test
fun testTlsTcpRequestResponse() = runBlocking {
val (serverContext, clientContext) = createTlsContexts()
val server = TcpServer(
"127.0.0.1",
0,
serverContext,
{ socket -> TcpClient.fromSocket(socket, 0, 0, testParserMap()) },
)
server.onClientConnected = { client ->
addRequestListenerTyped<TestData, TestData>(client.communicator) { req ->
TestData.newBuilder()
.setIndex(req.index * 2)
.setMessage("tls echo: ${req.message}")
.build()
}
}
server.start()
val client = dialTcpTls("127.0.0.1", server.port(), clientContext, 0, 0, testParserMap())
val res = sendRequestTyped<TestData, TestData>(
client.communicator,
TestData.newBuilder().setIndex(22).setMessage("secure hello").build(),
timeoutMs = 2_000,
)
assertEquals(44, res.index)
assertEquals("tls echo: secure hello", res.message)
client.close()
server.stop()
}
private fun createTlsContexts(): Pair<javax.net.ssl.SSLContext, javax.net.ssl.SSLContext> {
val loader = Thread.currentThread().contextClassLoader
val certPath = Paths.get(loader.getResource("server.crt")!!.toURI()).toString()
val keyPath = Paths.get(loader.getResource("server.key")!!.toURI()).toString()
return createTestSslContexts(certPath, keyPath)
}
}

View file

@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDJTCCAg2gAwIBAgIUS6bQFlfAUTyEJcWHni/uLmDV5QYwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDQwNTA0NTUwM1oXDTM2MDQw
MjA0NTUwM1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAqRXXGrJkOdQc/Ob7fuXHmYm5QWplwnvu/y/qgjVzCe3M
+TTsoQUEJFCKc3J/riJd/Td9wX16uINRDvmciN1p2xjY+BRNULt0ElArFDAgHR35
OniCq4EkuKDsXvHZ5pZBF+ZDMJS/v7gSWQ36qDumKvJcQgsDz7eSUGd9TwFSQE0a
CHXsyeRBwg3xsOsfm/v0TkEAjraifg5S5nuJlK0fZV+s+yFe7KoMI3cV2I5yzFgB
6vkWwNuM6bk/E/vzBuUnbWLAQIGFFFw7Fz9xylBwNDqHn8v9olqe6pVRpKMm6LON
VoxWENIXFPJ2kyC18l4DK2nGAfF0hm6zrZ1yQkSCqQIDAQABo28wbTAdBgNVHQ4E
FgQUDzMwNEwoYhF213Lu2izSiwjNGkQwHwYDVR0jBBgwFoAUDzMwNEwoYhF213Lu
2izSiwjNGkQwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARhwR/AAABgglsb2Nh
bGhvc3QwDQYJKoZIhvcNAQELBQADggEBAIuKQm6sb/i6vrnKjjFgAb0RdoUIR06b
ByOAyd+sIo8CUsI27j36egaPuA6VBxhLZsbGWwPiZnxiFRKAPdSq2c2JebLECJtu
Brh5SJJplubMWf4TYZ7TW1t7IJetSj74Ut5fQbfboU6rbKL5g9OY9LxoSFRdYokl
N0olJV870Jh3gZmxCx/TrQqQedpOvHJUdeht4MRTXjmPtqYPgZCeZvleKp3fSps1
GFFlcerb597GMiNCckb1cEAnyraXZrHwkb4uoH1sWdaY4DOMZaXLoWftcd/iNpY0
0qILRMCiQaXFaHnUdN8F9BhSM0WU8w1YFlcBH0nGEk9scNrlzbWNAAA=
-----END CERTIFICATE-----

View file

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCpFdcasmQ51Bz8
5vt+5ceZiblBamXCe+7/L+qCNXMJ7cz5NOyhBQQkUIpzcn+uIl39N33BfXq4g1EO
+ZyI3WnbGNj4FE1Qu3QSUCsUMCAdHfk6eIKrgSS4oOxe8dnmlkEX5kMwlL+/uBJZ
DfqoO6Yq8lxCCwPPt5JQZ31PAVJATRoIdezJ5EHCDfGw6x+b+/ROQQCOtqJ+DlLm
e4mUrR9lX6z7IV7sqgwjdxXYjnLMWAHq+RbA24zpuT8T+/MG5SdtYsBAgYUUXDsX
P3HKUHA0Ooefy/2iWp7qlVGkoybos41WjFYQ0hcU8naTILXyXgMracYB8XSGbrOt
nXJCRIKpAgMBAAECggEACr+Oqu3IHTz0kscEGa71nzb4BcaDrXc/XA1ptNk89Nae
/wB0QlAVUVGlW21d3G3m15/daJ1XXb9LOc54OuMIRZswv6RavdUMrdVWx7O/dtpe
626Zr9lHwkzIeciZ92R5wtEqWD48ai2DTRHsayFPkM9TOgeFOIEM1fueJZWJ6vhh
QTBqaklzV4Z6i6zdW4uwf4M4yGBwnHd3KLurCNykj4yRqxUOPwVMOQpOs/eL/9AZ
HJOaoplh8lOiY6bk/wktSo+Rwv6l2b9aRmKfHQpAtMQiJxrZAtv6LB64bBNjllc3
jjWEqyswTkzLxQBBaKqy0MP+bwdtWECO6sZS3DGb+QKBgQDa0X1brHMfopgHwK5L
LQHnvGFDXbsPKM0wFqBUjKcIXiCutXm3BYhc7wwpEZ5qFYi3tUwAKPJKwliOA1xM
RyazBhSDz63RJTbyt8yqxt3toMJalO7QL6bfi9HBDJrICGGI0kIJ0yfi1jS1A6u+
pMaATBPbcVsuneXFHoklCFG95wKBgQDF0PwISNVxNdbglq4UcGMgKO9UQnt0D5DM
R0OQMLkws9nFowOgR0RjelCj4Xf2kHWELUZkf9JdlAv4RIgDyc+j+1jJIE1xyjOp
HGm2sEh+dS89b1WcsLSq1wcgVmp3GqX26QCBC6dJak2LX8UxvyUAI90ntLph8vzB
IIQprTMI7wKBgH3WhcoHpXkm71leBJ309yiGnES6jY3NQBsMmA2niZN1VkRC1wla
5C3Vx1+C42BMnABSAbAB3D0EARtCcXzoWigQMhIPh/1D8pKpAsmfbhdvIPYouiH9
lXDvnqPvlL++miCuEg5GYaTA3TTQNJ+BcSptFepYCUEIyw+OXP5wB1o3AoGAOx1+
A/fIGWHuigVdlmwTo2u4QeTwQbnZGsL9NNzqqtxEaySRE9fYXyYRbTgXAo8fH1Xs
YGI2epKglRvdzcwEiku1t704h5XWpGYCTX8W2vuoF2LrIb1I8Hj7/zTz8g37pPLy
nJ3f6zeiXtFK+9fUddtB3vjKbjUQRaKy/EVvJ+8CgYBGI9YMFMtMrtMo/zpsuNFu
Mu3p7w5kdrE0S8hYmS1jy2M6L/2yCWY/G1Rly/VWfPUWpvBZO8pi5mg+dIdGPk4z
FEg5+qrDojg5QNx4WKjf5ybLCbiKKoiIFYtzW+OsK3bPfp/t9+smoiGHWWNPNzff
2sgiy+qXLf//H3KbjG8cwg==
-----END PRIVATE KEY-----

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "toki-socket"
version = "0.1.0"
version = "1.0.5"
description = "Python implementation of the Toki Socket binary socket protocol."
readme = "README.md"
requires-python = ">=3.11"
@ -25,4 +25,3 @@ include = ["toki_socket*"]
[tool.setuptools.package-data]
"toki_socket.packets" = ["*.proto", "*.pyi"]

View file

@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDJTCCAg2gAwIBAgIUS6bQFlfAUTyEJcWHni/uLmDV5QYwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDQwNTA0NTUwM1oXDTM2MDQw
MjA0NTUwM1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAqRXXGrJkOdQc/Ob7fuXHmYm5QWplwnvu/y/qgjVzCe3M
+TTsoQUEJFCKc3J/riJd/Td9wX16uINRDvmciN1p2xjY+BRNULt0ElArFDAgHR35
OniCq4EkuKDsXvHZ5pZBF+ZDMJS/v7gSWQ36qDumKvJcQgsDz7eSUGd9TwFSQE0a
CHXsyeRBwg3xsOsfm/v0TkEAjraifg5S5nuJlK0fZV+s+yFe7KoMI3cV2I5yzFgB
6vkWwNuM6bk/E/vzBuUnbWLAQIGFFFw7Fz9xylBwNDqHn8v9olqe6pVRpKMm6LON
VoxWENIXFPJ2kyC18l4DK2nGAfF0hm6zrZ1yQkSCqQIDAQABo28wbTAdBgNVHQ4E
FgQUDzMwNEwoYhF213Lu2izSiwjNGkQwHwYDVR0jBBgwFoAUDzMwNEwoYhF213Lu
2izSiwjNGkQwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARhwR/AAABgglsb2Nh
bGhvc3QwDQYJKoZIhvcNAQELBQADggEBAIuKQm6sb/i6vrnKjjFgAb0RdoUIR06b
ByOAyd+sIo8CUsI27j36egaPuA6VBxhLZsbGWwPiZnxiFRKAPdSq2c2JebLECJtu
Brh5SJJplubMWf4TYZ7TW1t7IJetSj74Ut5fQbfboU6rbKL5g9OY9LxoSFRdYokl
N0olJV870Jh3gZmxCx/TrQqQedpOvHJUdeht4MRTXjmPtqYPgZCeZvleKp3fSps1
GFFlcerb597GMiNCckb1cEAnyraXZrHwkb4uoH1sWdaY4DOMZaXLoWftcd/iNpY0
0qILRMCiQaXFaHnUdN8F9BhSM0WU8w1YFlcBH0nGEk9scNrlzbWNAAA=
-----END CERTIFICATE-----

View file

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCpFdcasmQ51Bz8
5vt+5ceZiblBamXCe+7/L+qCNXMJ7cz5NOyhBQQkUIpzcn+uIl39N33BfXq4g1EO
+ZyI3WnbGNj4FE1Qu3QSUCsUMCAdHfk6eIKrgSS4oOxe8dnmlkEX5kMwlL+/uBJZ
DfqoO6Yq8lxCCwPPt5JQZ31PAVJATRoIdezJ5EHCDfGw6x+b+/ROQQCOtqJ+DlLm
e4mUrR9lX6z7IV7sqgwjdxXYjnLMWAHq+RbA24zpuT8T+/MG5SdtYsBAgYUUXDsX
P3HKUHA0Ooefy/2iWp7qlVGkoybos41WjFYQ0hcU8naTILXyXgMracYB8XSGbrOt
nXJCRIKpAgMBAAECggEACr+Oqu3IHTz0kscEGa71nzb4BcaDrXc/XA1ptNk89Nae
/wB0QlAVUVGlW21d3G3m15/daJ1XXb9LOc54OuMIRZswv6RavdUMrdVWx7O/dtpe
626Zr9lHwkzIeciZ92R5wtEqWD48ai2DTRHsayFPkM9TOgeFOIEM1fueJZWJ6vhh
QTBqaklzV4Z6i6zdW4uwf4M4yGBwnHd3KLurCNykj4yRqxUOPwVMOQpOs/eL/9AZ
HJOaoplh8lOiY6bk/wktSo+Rwv6l2b9aRmKfHQpAtMQiJxrZAtv6LB64bBNjllc3
jjWEqyswTkzLxQBBaKqy0MP+bwdtWECO6sZS3DGb+QKBgQDa0X1brHMfopgHwK5L
LQHnvGFDXbsPKM0wFqBUjKcIXiCutXm3BYhc7wwpEZ5qFYi3tUwAKPJKwliOA1xM
RyazBhSDz63RJTbyt8yqxt3toMJalO7QL6bfi9HBDJrICGGI0kIJ0yfi1jS1A6u+
pMaATBPbcVsuneXFHoklCFG95wKBgQDF0PwISNVxNdbglq4UcGMgKO9UQnt0D5DM
R0OQMLkws9nFowOgR0RjelCj4Xf2kHWELUZkf9JdlAv4RIgDyc+j+1jJIE1xyjOp
HGm2sEh+dS89b1WcsLSq1wcgVmp3GqX26QCBC6dJak2LX8UxvyUAI90ntLph8vzB
IIQprTMI7wKBgH3WhcoHpXkm71leBJ309yiGnES6jY3NQBsMmA2niZN1VkRC1wla
5C3Vx1+C42BMnABSAbAB3D0EARtCcXzoWigQMhIPh/1D8pKpAsmfbhdvIPYouiH9
lXDvnqPvlL++miCuEg5GYaTA3TTQNJ+BcSptFepYCUEIyw+OXP5wB1o3AoGAOx1+
A/fIGWHuigVdlmwTo2u4QeTwQbnZGsL9NNzqqtxEaySRE9fYXyYRbTgXAo8fH1Xs
YGI2epKglRvdzcwEiku1t704h5XWpGYCTX8W2vuoF2LrIb1I8Hj7/zTz8g37pPLy
nJ3f6zeiXtFK+9fUddtB3vjKbjUQRaKy/EVvJ+8CgYBGI9YMFMtMrtMo/zpsuNFu
Mu3p7w5kdrE0S8hYmS1jy2M6L/2yCWY/G1Rly/VWfPUWpvBZO8pi5mg+dIdGPk4z
FEg5+qrDojg5QNx4WKjf5ybLCbiKKoiIFYtzW+OsK3bPfp/t9+smoiGHWWNPNzff
2sgiy+qXLf//H3KbjG8cwg==
-----END PRIVATE KEY-----

View file

@ -4,7 +4,7 @@ import asyncio
import pytest
from toki_socket.communicator import Communicator, type_name_of
from toki_socket.communicator import MAX_NONCE, Communicator, type_name_of
from toki_socket.packets.message_common_pb2 import PacketBase, TestData as ProtoTestData
@ -90,6 +90,53 @@ async def test_send_request_response():
await communicator.close()
@pytest.mark.asyncio
async def test_nonce_wraps_after_int32_max_without_emitting_zero():
transport = FakeTransport()
communicator = Communicator()
communicator.initialize(transport, parser_map())
communicator._nonce = MAX_NONCE - 1
await communicator.send(ProtoTestData(index=1))
await communicator.send(ProtoTestData(index=2))
assert [packet.nonce for packet in transport.packets] == [MAX_NONCE, 1]
assert all(packet.nonce != 0 for packet in transport.packets)
await communicator.close()
@pytest.mark.asyncio
async def test_send_request_nonce_wraps_at_int32_max():
transport = FakeTransport()
communicator = Communicator()
communicator.initialize(transport, parser_map())
communicator._nonce = MAX_NONCE - 1
task = asyncio.create_task(
communicator.send_request(ProtoTestData(index=1, message="max"), ProtoTestData, timeout=1)
)
for _ in range(20):
if transport.packets:
break
await asyncio.sleep(0.01)
request = transport.packets[0]
assert request.nonce == MAX_NONCE
assert request.nonce != 0
communicator.on_received_data(
type_name_of(ProtoTestData),
ProtoTestData(index=2, message="max response").SerializeToString(),
nonce=1,
response_nonce=request.nonce,
)
result = await task
assert result.message == "max response"
await communicator.close()
@pytest.mark.asyncio
async def test_shutdown():
transport = FakeTransport()

View file

@ -1,15 +1,32 @@
from __future__ import annotations
import asyncio
import ssl
from pathlib import Path
import pytest
from toki_socket.communicator import type_name_of
from toki_socket.packets.message_common_pb2 import TestData as ProtoTestData
from toki_socket.tcp_client import TcpClient, connect_tcp
from toki_socket.tcp_client import TcpClient, connect_tcp, connect_tcp_tls
from toki_socket.tcp_server import TcpServer
def _make_server_ssl_context() -> ssl.SSLContext:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
certs_dir = Path(__file__).parent / "certs"
ctx.load_cert_chain(certs_dir / "server.crt", certs_dir / "server.key")
return ctx
def _make_client_ssl_context() -> ssl.SSLContext:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
certs_dir = Path(__file__).parent / "certs"
ctx.load_verify_locations(certs_dir / "server.crt")
ctx.check_hostname = False
return ctx
async def _wait_for_clients(server: TcpServer, count: int = 1, timeout: float = 1.0) -> None:
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
@ -76,6 +93,74 @@ async def test_tcp_request_response():
await server.stop()
@pytest.mark.asyncio
async def test_tcp_tls_send_receive():
received = asyncio.get_running_loop().create_future()
server = TcpServer(
"127.0.0.1",
0,
lambda reader, writer: TcpClient(reader, writer, 0, 0, parser_map()),
ssl_context=_make_server_ssl_context(),
)
server.on_client_connected = lambda client: client.communicator.add_listener(
type_name_of(ProtoTestData),
lambda message: received.set_result(message) if not received.done() else None,
)
await server.start()
client = await connect_tcp_tls(
"127.0.0.1",
server.port,
_make_client_ssl_context(),
0,
0,
parser_map(),
)
await client.communicator.send(ProtoTestData(index=3, message="hello over tls"))
result = await asyncio.wait_for(received, 2)
assert result.index == 3
assert result.message == "hello over tls"
await client.close()
await server.stop()
@pytest.mark.asyncio
async def test_tcp_tls_request_response():
server = TcpServer(
"127.0.0.1",
0,
lambda reader, writer: TcpClient(reader, writer, 0, 0, parser_map()),
ssl_context=_make_server_ssl_context(),
)
server.on_client_connected = lambda client: client.communicator.add_request_listener(
type_name_of(ProtoTestData),
lambda req: ProtoTestData(index=req.index * 2, message="tls echo: " + req.message),
)
await server.start()
client = await connect_tcp_tls(
"127.0.0.1",
server.port,
_make_client_ssl_context(),
0,
0,
parser_map(),
)
result = await client.communicator.send_request(
ProtoTestData(index=24, message="secure request"),
ProtoTestData,
timeout=2,
)
assert result.index == 48
assert result.message == "tls echo: secure request"
await client.close()
await server.stop()
@pytest.mark.asyncio
async def test_tcp_server_push():
pushed = asyncio.get_running_loop().create_future()

View file

@ -1,15 +1,32 @@
from __future__ import annotations
import asyncio
import ssl
from pathlib import Path
import pytest
from toki_socket.communicator import type_name_of
from toki_socket.packets.message_common_pb2 import TestData as ProtoTestData
from toki_socket.ws_client import WsClient, connect_ws
from toki_socket.ws_client import WsClient, connect_ws, connect_wss
from toki_socket.ws_server import WsServer
def _make_server_ssl_context() -> ssl.SSLContext:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
certs_dir = Path(__file__).parent / "certs"
ctx.load_cert_chain(certs_dir / "server.crt", certs_dir / "server.key")
return ctx
def _make_client_ssl_context() -> ssl.SSLContext:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
certs_dir = Path(__file__).parent / "certs"
ctx.load_verify_locations(certs_dir / "server.crt")
ctx.check_hostname = False
return ctx
def parser_map():
return {type_name_of(ProtoTestData): ProtoTestData.FromString}
@ -77,6 +94,78 @@ async def test_ws_server_push():
await server.stop()
@pytest.mark.asyncio
async def test_ws_tls_send_receive():
received = asyncio.get_running_loop().create_future()
server = WsServer(
"127.0.0.1",
0,
"/",
lambda ws: WsClient(ws, 0, 0, parser_map()),
ssl_context=_make_server_ssl_context(),
)
server.on_client_connected = lambda client: client.communicator.add_listener(
type_name_of(ProtoTestData),
lambda message: received.set_result(message) if not received.done() else None,
)
await server.start()
client = await connect_wss(
"127.0.0.1",
server.port,
"/",
_make_client_ssl_context(),
0,
0,
parser_map(),
)
await client.communicator.send(ProtoTestData(index=4, message="hello wss"))
result = await asyncio.wait_for(received, 2)
assert result.index == 4
assert result.message == "hello wss"
await client.close()
await server.stop()
@pytest.mark.asyncio
async def test_ws_tls_request_response():
server = WsServer(
"127.0.0.1",
0,
"/",
lambda ws: WsClient(ws, 0, 0, parser_map()),
ssl_context=_make_server_ssl_context(),
)
server.on_client_connected = lambda client: client.communicator.add_request_listener(
type_name_of(ProtoTestData),
lambda req: ProtoTestData(index=req.index * 2, message="wss echo: " + req.message),
)
await server.start()
client = await connect_wss(
"127.0.0.1",
server.port,
"/",
_make_client_ssl_context(),
0,
0,
parser_map(),
)
result = await client.communicator.send_request(
ProtoTestData(index=25, message="wss request"),
ProtoTestData,
timeout=2,
)
assert result.index == 50
assert result.message == "wss echo: wss request"
await client.close()
await server.stop()
@pytest.mark.asyncio
async def test_ws_request_response():
server = WsServer(

View file

@ -6,9 +6,9 @@ from toki_socket.communicator import (
Transport,
type_name_of,
)
from toki_socket.tcp_client import MAX_PACKET_SIZE, TcpClient, connect_tcp
from toki_socket.tcp_client import MAX_PACKET_SIZE, TcpClient, connect_tcp, connect_tcp_tls
from toki_socket.tcp_server import TcpServer
from toki_socket.ws_client import WsClient, connect_ws
from toki_socket.ws_client import WsClient, connect_ws, connect_wss
from toki_socket.ws_server import WsServer
__all__ = [
@ -23,7 +23,8 @@ __all__ = [
"WsClient",
"WsServer",
"connect_tcp",
"connect_tcp_tls",
"connect_ws",
"connect_wss",
"type_name_of",
]

View file

@ -14,6 +14,7 @@ ParserMap: TypeAlias = dict[str, Callable[[bytes], Message]]
RequestHandler: TypeAlias = Callable[..., Message | Awaitable[Message | None] | None]
_STOP = object()
MAX_NONCE = 2_147_483_647
class NotConnectedError(RuntimeError):
@ -61,6 +62,8 @@ class Communicator:
self._write_error_handler = fn
def next_nonce(self) -> int:
if self._nonce >= MAX_NONCE:
self._nonce = 0
self._nonce += 1
return self._nonce

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import ssl
import struct
from contextlib import suppress
@ -82,3 +83,14 @@ async def connect_tcp(
reader, writer = await asyncio.open_connection(host, port)
return TcpClient(reader, writer, interval_sec, wait_sec, parser_map)
async def connect_tcp_tls(
host: str,
port: int,
ssl_context: ssl.SSLContext,
interval_sec: int,
wait_sec: int,
parser_map: ParserMap,
) -> TcpClient:
reader, writer = await asyncio.open_connection(host, port, ssl=ssl_context)
return TcpClient(reader, writer, interval_sec, wait_sec, parser_map)

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import inspect
import ssl
from collections.abc import Callable
from google.protobuf.message import Message
@ -19,6 +20,7 @@ class TcpServer:
interval_sec: int = 0,
wait_sec: int = 0,
parser_map: ParserMap | None = None,
ssl_context: ssl.SSLContext | None = None,
) -> None:
self._host = host
self._port = port
@ -26,6 +28,7 @@ class TcpServer:
self._interval_sec = interval_sec
self._wait_sec = wait_sec
self._parser_map = parser_map
self._ssl_context = ssl_context
self._clients: list[TcpClient] = []
self._server: asyncio.Server | None = None
self.on_client_connected: Callable[[TcpClient], object] = lambda _: None
@ -40,7 +43,12 @@ class TcpServer:
return list(self._clients)
async def start(self) -> None:
self._server = await asyncio.start_server(self._handle_connection, self._host, self._port)
self._server = await asyncio.start_server(
self._handle_connection,
self._host,
self._port,
ssl=self._ssl_context,
)
async def stop(self) -> None:
if self._server is not None:
@ -77,4 +85,3 @@ class TcpServer:
def _remove_client(self, client: TcpClient) -> None:
if client in self._clients:
self._clients.remove(client)

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import ssl
from contextlib import suppress
from typing import Any
@ -78,3 +79,17 @@ async def connect_ws(
uri = f"ws://{host}:{port}{path}"
ws = await ws_connect(uri)
return WsClient(ws, interval_sec, wait_sec, parser_map)
async def connect_wss(
host: str,
port: int,
path: str,
ssl_context: ssl.SSLContext,
interval_sec: int,
wait_sec: int,
parser_map: ParserMap,
) -> WsClient:
uri = f"wss://{host}:{port}{path}"
ws = await ws_connect(uri, ssl=ssl_context)
return WsClient(ws, interval_sec, wait_sec, parser_map)

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import inspect
import ssl
from collections.abc import Callable
from typing import Any
@ -29,6 +30,7 @@ class WsServer:
interval_sec: int = 0,
wait_sec: int = 0,
parser_map: ParserMap | None = None,
ssl_context: ssl.SSLContext | None = None,
) -> None:
self._host = host
self._port = port
@ -37,6 +39,7 @@ class WsServer:
self._interval_sec = interval_sec
self._wait_sec = wait_sec
self._parser_map = parser_map
self._ssl_context = ssl_context
self._clients: list[WsClient] = []
self._server: Any | None = None
self.on_client_connected: Callable[[WsClient], object] = lambda _: None
@ -52,7 +55,7 @@ class WsServer:
async def start(self) -> None:
handler = self._handle_websocket if _NEW_WEBSOCKETS_API else self._handle_legacy_websocket
self._server = await ws_serve(handler, self._host, self._port)
self._server = await ws_serve(handler, self._host, self._port, ssl=self._ssl_context)
async def stop(self) -> None:
if self._server is not None:

View file

@ -1,12 +1,12 @@
{
"name": "toki-socket",
"version": "0.1.0",
"version": "1.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "toki-socket",
"version": "0.1.0",
"version": "1.0.5",
"dependencies": {
"@bufbuild/protobuf": "^2.2.5",
"ws": "^8.18.1"

View file

@ -1,6 +1,6 @@
{
"name": "toki-socket",
"version": "0.1.0",
"version": "1.0.5",
"private": true,
"type": "module",
"scripts": {

View file

@ -53,6 +53,7 @@ export function parserFromSchema<T extends Message>(schema: MessageType<T>): Mes
export class Communicator {
private static readonly MAX_WRITE_QUEUE_SIZE = 64;
private static readonly MAX_NONCE = 2_147_483_647;
private nonce = 0;
private isAliveFlag = false;
@ -109,6 +110,9 @@ export class Communicator {
}
private nextNonce(): number {
if (this.nonce >= Communicator.MAX_NONCE) {
this.nonce = 0;
}
this.nonce += 1;
return this.nonce;
}

View file

@ -1,4 +1,5 @@
import * as net from "node:net";
import * as tls from "node:tls";
import { fromBinary, toBinary } from "@bufbuild/protobuf";
@ -104,3 +105,31 @@ export async function connectTcp(
socket.once("connect", onConnect);
});
}
export async function connectTcpTls(
host: string,
port: number,
tlsOptions: tls.ConnectionOptions,
intervalSec: number,
waitSec: number,
parserMap: ParserMap,
): Promise<TcpClient> {
return new Promise<TcpClient>((resolve, reject) => {
const socket = tls.connect({ ...tlsOptions, host, port });
const cleanup = () => {
socket.off("secureConnect", onSecureConnect);
socket.off("error", onError);
};
const onSecureConnect = () => {
cleanup();
resolve(new TcpClient(socket, intervalSec, waitSec, parserMap));
};
const onError = (err: Error) => {
cleanup();
reject(err);
};
socket.once("secureConnect", onSecureConnect);
socket.once("error", onError);
});
}

View file

@ -1,11 +1,12 @@
import * as net from "node:net";
import * as tls from "node:tls";
import { type Message } from "@bufbuild/protobuf";
import { TcpClient } from "./tcp_client.js";
export class TcpServer {
private readonly server: net.Server;
private readonly server: net.Server | tls.Server;
private readonly clients = new Set<TcpClient>();
private started = false;
@ -15,15 +16,18 @@ export class TcpServer {
private readonly host: string,
private readonly listenPort: number,
private readonly newClient: (socket: net.Socket) => TcpClient,
tlsOptions?: tls.TlsOptions,
) {
this.server = net.createServer((socket) => {
const handleSocket = (socket: net.Socket): void => {
const client = this.newClient(socket);
this.clients.add(client);
client.addDisconnectListener(() => {
this.removeClient(client);
});
this.onClientConnected(client);
});
};
this.server = tlsOptions ? tls.createServer(tlsOptions, handleSocket) : net.createServer(handleSocket);
}
get port(): number {

View file

@ -1,4 +1,4 @@
import WebSocket, { type RawData } from "ws";
import WebSocket, { type ClientOptions, type RawData } from "ws";
import { fromBinary, toBinary } from "@bufbuild/protobuf";
@ -81,6 +81,35 @@ export async function connectWs(
});
}
export async function connectWss(
host: string,
port: number,
path: string,
wsOptions: ClientOptions,
intervalSec: number,
waitSec: number,
parserMap: ParserMap,
): Promise<WsClient> {
return new Promise<WsClient>((resolve, reject) => {
const ws = new WebSocket(`wss://${host}:${port}${path}`, wsOptions);
const onOpen = () => {
cleanup();
resolve(new WsClient(ws, intervalSec, waitSec, parserMap));
};
const onError = (err: Error) => {
cleanup();
reject(err);
};
const cleanup = () => {
ws.off("open", onOpen);
ws.off("error", onError);
};
ws.once("open", onOpen);
ws.once("error", onError);
});
}
function toUint8Array(data: RawData): Uint8Array {
if (Array.isArray(data)) {
return Buffer.concat(data.map((chunk) => Buffer.from(chunk)));

View file

@ -1,3 +1,5 @@
import * as https from "node:https";
import { type Message } from "@bufbuild/protobuf";
import WebSocket, { WebSocketServer } from "ws";
@ -5,6 +7,7 @@ import { WsClient } from "./ws_client.js";
export class WsServer {
private wss: WebSocketServer | null = null;
private httpsServer: https.Server | null = null;
private readonly clients = new Set<WsClient>();
onClientConnected: (client: WsClient) => void = () => {};
@ -14,10 +17,11 @@ export class WsServer {
private readonly listenPort: number,
private readonly path: string,
private readonly newClient: (ws: WebSocket) => WsClient,
private readonly tlsOptions?: https.ServerOptions,
) {}
get port(): number {
const address = this.wss?.address();
const address = this.httpsServer?.address() ?? this.wss?.address();
if (address !== undefined && address !== null && typeof address === "object") {
return address.port;
}
@ -30,11 +34,14 @@ export class WsServer {
}
return new Promise<void>((resolve, reject) => {
const wss = new WebSocketServer({
host: this.host,
path: this.path,
port: this.listenPort,
});
const httpsServer = this.tlsOptions ? https.createServer(this.tlsOptions) : null;
const wss = httpsServer
? new WebSocketServer({ server: httpsServer, path: this.path })
: new WebSocketServer({
host: this.host,
path: this.path,
port: this.listenPort,
});
const onError = (err: Error) => {
cleanup();
reject(err);
@ -42,23 +49,25 @@ export class WsServer {
const onListening = () => {
cleanup();
this.wss = wss;
this.httpsServer = httpsServer;
resolve();
};
const cleanup = () => {
wss.off("error", onError);
wss.off("listening", onListening);
httpsServer?.off("error", onError);
httpsServer?.off("listening", onListening);
};
wss.once("error", onError);
wss.once("listening", onListening);
wss.on("connection", (ws) => {
const client = this.newClient(ws);
this.clients.add(client);
client.addDisconnectListener(() => {
this.removeClient(client);
});
this.onClientConnected(client);
});
wss.on("connection", (ws) => this.handleConnection(ws));
if (httpsServer !== null) {
httpsServer.once("error", onError);
httpsServer.once("listening", onListening);
httpsServer.listen(this.listenPort, this.host);
} else {
wss.once("listening", onListening);
}
});
}
@ -68,7 +77,9 @@ export class WsServer {
}
const wss = this.wss;
const httpsServer = this.httpsServer;
this.wss = null;
this.httpsServer = null;
const clients = [...this.clients];
this.clients.clear();
const closeServer = new Promise<void>((resolve, reject) => {
@ -80,9 +91,22 @@ export class WsServer {
resolve();
});
});
const closeHttpsServer =
httpsServer === null
? Promise.resolve()
: new Promise<void>((resolve, reject) => {
httpsServer.close((err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
await Promise.allSettled(clients.map(async (client) => client.close()));
await closeServer;
await closeHttpsServer;
}
async broadcast(msg: Message): Promise<void> {
@ -92,4 +116,13 @@ export class WsServer {
private removeClient(client: WsClient): void {
this.clients.delete(client);
}
private handleConnection(ws: WebSocket): void {
const client = this.newClient(ws);
this.clients.add(client);
client.addDisconnectListener(() => {
this.removeClient(client);
});
this.onClientConnected(client);
}
}

View file

@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDJTCCAg2gAwIBAgIUS6bQFlfAUTyEJcWHni/uLmDV5QYwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDQwNTA0NTUwM1oXDTM2MDQw
MjA0NTUwM1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAqRXXGrJkOdQc/Ob7fuXHmYm5QWplwnvu/y/qgjVzCe3M
+TTsoQUEJFCKc3J/riJd/Td9wX16uINRDvmciN1p2xjY+BRNULt0ElArFDAgHR35
OniCq4EkuKDsXvHZ5pZBF+ZDMJS/v7gSWQ36qDumKvJcQgsDz7eSUGd9TwFSQE0a
CHXsyeRBwg3xsOsfm/v0TkEAjraifg5S5nuJlK0fZV+s+yFe7KoMI3cV2I5yzFgB
6vkWwNuM6bk/E/vzBuUnbWLAQIGFFFw7Fz9xylBwNDqHn8v9olqe6pVRpKMm6LON
VoxWENIXFPJ2kyC18l4DK2nGAfF0hm6zrZ1yQkSCqQIDAQABo28wbTAdBgNVHQ4E
FgQUDzMwNEwoYhF213Lu2izSiwjNGkQwHwYDVR0jBBgwFoAUDzMwNEwoYhF213Lu
2izSiwjNGkQwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARhwR/AAABgglsb2Nh
bGhvc3QwDQYJKoZIhvcNAQELBQADggEBAIuKQm6sb/i6vrnKjjFgAb0RdoUIR06b
ByOAyd+sIo8CUsI27j36egaPuA6VBxhLZsbGWwPiZnxiFRKAPdSq2c2JebLECJtu
Brh5SJJplubMWf4TYZ7TW1t7IJetSj74Ut5fQbfboU6rbKL5g9OY9LxoSFRdYokl
N0olJV870Jh3gZmxCx/TrQqQedpOvHJUdeht4MRTXjmPtqYPgZCeZvleKp3fSps1
GFFlcerb597GMiNCckb1cEAnyraXZrHwkb4uoH1sWdaY4DOMZaXLoWftcd/iNpY0
0qILRMCiQaXFaHnUdN8F9BhSM0WU8w1YFlcBH0nGEk9scNrlzbWNAAA=
-----END CERTIFICATE-----

View file

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCpFdcasmQ51Bz8
5vt+5ceZiblBamXCe+7/L+qCNXMJ7cz5NOyhBQQkUIpzcn+uIl39N33BfXq4g1EO
+ZyI3WnbGNj4FE1Qu3QSUCsUMCAdHfk6eIKrgSS4oOxe8dnmlkEX5kMwlL+/uBJZ
DfqoO6Yq8lxCCwPPt5JQZ31PAVJATRoIdezJ5EHCDfGw6x+b+/ROQQCOtqJ+DlLm
e4mUrR9lX6z7IV7sqgwjdxXYjnLMWAHq+RbA24zpuT8T+/MG5SdtYsBAgYUUXDsX
P3HKUHA0Ooefy/2iWp7qlVGkoybos41WjFYQ0hcU8naTILXyXgMracYB8XSGbrOt
nXJCRIKpAgMBAAECggEACr+Oqu3IHTz0kscEGa71nzb4BcaDrXc/XA1ptNk89Nae
/wB0QlAVUVGlW21d3G3m15/daJ1XXb9LOc54OuMIRZswv6RavdUMrdVWx7O/dtpe
626Zr9lHwkzIeciZ92R5wtEqWD48ai2DTRHsayFPkM9TOgeFOIEM1fueJZWJ6vhh
QTBqaklzV4Z6i6zdW4uwf4M4yGBwnHd3KLurCNykj4yRqxUOPwVMOQpOs/eL/9AZ
HJOaoplh8lOiY6bk/wktSo+Rwv6l2b9aRmKfHQpAtMQiJxrZAtv6LB64bBNjllc3
jjWEqyswTkzLxQBBaKqy0MP+bwdtWECO6sZS3DGb+QKBgQDa0X1brHMfopgHwK5L
LQHnvGFDXbsPKM0wFqBUjKcIXiCutXm3BYhc7wwpEZ5qFYi3tUwAKPJKwliOA1xM
RyazBhSDz63RJTbyt8yqxt3toMJalO7QL6bfi9HBDJrICGGI0kIJ0yfi1jS1A6u+
pMaATBPbcVsuneXFHoklCFG95wKBgQDF0PwISNVxNdbglq4UcGMgKO9UQnt0D5DM
R0OQMLkws9nFowOgR0RjelCj4Xf2kHWELUZkf9JdlAv4RIgDyc+j+1jJIE1xyjOp
HGm2sEh+dS89b1WcsLSq1wcgVmp3GqX26QCBC6dJak2LX8UxvyUAI90ntLph8vzB
IIQprTMI7wKBgH3WhcoHpXkm71leBJ309yiGnES6jY3NQBsMmA2niZN1VkRC1wla
5C3Vx1+C42BMnABSAbAB3D0EARtCcXzoWigQMhIPh/1D8pKpAsmfbhdvIPYouiH9
lXDvnqPvlL++miCuEg5GYaTA3TTQNJ+BcSptFepYCUEIyw+OXP5wB1o3AoGAOx1+
A/fIGWHuigVdlmwTo2u4QeTwQbnZGsL9NNzqqtxEaySRE9fYXyYRbTgXAo8fH1Xs
YGI2epKglRvdzcwEiku1t704h5XWpGYCTX8W2vuoF2LrIb1I8Hj7/zTz8g37pPLy
nJ3f6zeiXtFK+9fUddtB3vjKbjUQRaKy/EVvJ+8CgYBGI9YMFMtMrtMo/zpsuNFu
Mu3p7w5kdrE0S8hYmS1jy2M6L/2yCWY/G1Rly/VWfPUWpvBZO8pi5mg+dIdGPk4z
FEg5+qrDojg5QNx4WKjf5ybLCbiKKoiIFYtzW+OsK3bPfp/t9+smoiGHWWNPNzff
2sgiy+qXLf//H3KbjG8cwg==
-----END PRIVATE KEY-----

View file

@ -13,6 +13,8 @@ import {
type PacketBase,
} from "../src/packets/message_common_pb.js";
const MAX_NONCE = 2_147_483_647;
class MockTransport implements Transport {
packets: PacketBase[] = [];
@ -104,6 +106,19 @@ describe("Communicator", () => {
expect(transport.packets[1]?.nonce).toBe(2);
});
test("nonce wraps after int32 max without emitting zero", async () => {
const transport = new MockTransport();
const comm = new Communicator();
comm.initialize(transport, parserMap());
(comm as unknown as { nonce: number }).nonce = MAX_NONCE - 1;
await comm.send(create(TestDataSchema, { index: 1, message: "first" }));
await comm.send(create(TestDataSchema, { index: 2, message: "second" }));
expect(transport.packets.map((sentPacket) => sentPacket.nonce)).toEqual([MAX_NONCE, 1]);
expect(transport.packets.every((sentPacket) => sentPacket.nonce !== 0)).toBe(true);
});
test("sendRequest resolves on response", async () => {
const transport = new MockTransport();
const comm = new Communicator();
@ -131,6 +146,35 @@ describe("Communicator", () => {
});
});
test("sendRequest nonce wraps at int32 max", async () => {
const transport = new MockTransport();
const comm = new Communicator();
comm.initialize(transport, parserMap());
(comm as unknown as { nonce: number }).nonce = MAX_NONCE - 1;
const pending = comm.sendRequest(
create(TestDataSchema, { index: 1, message: "max" }),
TestDataSchema,
1000,
);
const request = transport.packets[0];
expect(request?.nonce).toBe(MAX_NONCE);
expect(request?.nonce).not.toBe(0);
comm.onReceivedData(
TestDataSchema.typeName,
toBinary(TestDataSchema, create(TestDataSchema, { index: 2, message: "max response" })),
0,
request?.nonce ?? 0,
);
await expect(pending).resolves.toMatchObject({
index: 2,
message: "max response",
});
});
test("sendRequest rejects on timeout", async () => {
const comm = new Communicator();
comm.initialize(new MockTransport(), parserMap());

View file

@ -1,3 +1,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { create } from "@bufbuild/protobuf";
import { afterEach, describe, expect, test } from "vitest";
@ -8,9 +12,13 @@ import {
sendRequestTyped,
} from "../src/communicator.js";
import { TestDataSchema, type TestData } from "../src/packets/message_common_pb.js";
import { connectTcp, TcpClient } from "../src/tcp_client.js";
import { connectTcp, connectTcpTls, TcpClient } from "../src/tcp_client.js";
import { TcpServer } from "../src/tcp_server.js";
const certsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "certs");
const cert = fs.readFileSync(path.join(certsDir, "server.crt"));
const key = fs.readFileSync(path.join(certsDir, "server.key"));
function parserMap() {
return new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]);
}
@ -90,6 +98,87 @@ describe("TCP", () => {
});
});
test("connectTcpTls sends and receives TestData", async () => {
const server = new TcpServer(
"127.0.0.1",
0,
(socket) => new TcpClient(socket, 0, 0, parserMap()),
{ cert, key },
);
cleanup.push(async () => server.stop());
await server.start();
server.onClientConnected = (client) => {
addListenerTyped(client.communicator, TestDataSchema, async (msg) => {
if (msg.index === 12 && msg.message === "hello over tls") {
await client.communicator.send(
create(TestDataSchema, { index: 201, message: "push from tls server" }),
);
}
});
};
const client = await connectTcpTls(
"127.0.0.1",
server.port,
{ ca: cert, servername: "localhost" },
0,
0,
parserMap(),
);
cleanup.push(async () => client.close());
const pushed = waitForClientMessage(client);
await client.communicator.send(create(TestDataSchema, { index: 12, message: "hello over tls" }));
await expect(pushed).resolves.toMatchObject({
index: 201,
message: "push from tls server",
});
});
test("TLS sendRequest/response roundtrip", async () => {
const server = new TcpServer(
"127.0.0.1",
0,
(socket) => new TcpClient(socket, 0, 0, parserMap()),
{ cert, key },
);
cleanup.push(async () => server.stop());
await server.start();
server.onClientConnected = (client) => {
addRequestListenerTyped(client.communicator, TestDataSchema, (req) =>
create(TestDataSchema, {
index: req.index * 2,
message: `tls echo: ${req.message}`,
}),
);
};
const client = await connectTcpTls(
"127.0.0.1",
server.port,
{ ca: cert, servername: "localhost" },
0,
0,
parserMap(),
);
cleanup.push(async () => client.close());
await expect(
sendRequestTyped(
client.communicator,
create(TestDataSchema, { index: 22, message: "request over tls" }),
TestDataSchema,
500,
),
).resolves.toMatchObject({
index: 44,
message: "tls echo: request over tls",
});
});
test("concurrent sendRequest/response roundtrip over TCP", async () => {
const server = new TcpServer("127.0.0.1", 0, (socket) => new TcpClient(socket, 0, 0, parserMap()));
cleanup.push(async () => server.stop());

View file

@ -1,3 +1,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { create } from "@bufbuild/protobuf";
import { afterEach, describe, expect, test } from "vitest";
@ -8,9 +12,13 @@ import {
sendRequestTyped,
} from "../src/communicator.js";
import { TestDataSchema, type TestData } from "../src/packets/message_common_pb.js";
import { connectWs, WsClient } from "../src/ws_client.js";
import { connectWs, connectWss, WsClient } from "../src/ws_client.js";
import { WsServer } from "../src/ws_server.js";
const certsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "certs");
const cert = fs.readFileSync(path.join(certsDir, "server.crt"));
const key = fs.readFileSync(path.join(certsDir, "server.key"));
function parserMap() {
return new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]);
}
@ -90,6 +98,85 @@ describe("WS", () => {
});
});
test("connectWss sends and receives TestData", async () => {
const server = new WsServer("127.0.0.1", 0, "/", (ws) => new WsClient(ws, 0, 0, parserMap()), {
cert,
key,
});
cleanup.push(async () => server.stop());
await server.start();
server.onClientConnected = (client) => {
addListenerTyped(client.communicator, TestDataSchema, async (msg) => {
if (msg.index === 12 && msg.message === "hello over wss") {
await client.communicator.send(
create(TestDataSchema, { index: 201, message: "push from wss server" }),
);
}
});
};
const client = await connectWss(
"127.0.0.1",
server.port,
"/",
{ ca: cert },
0,
0,
parserMap(),
);
cleanup.push(async () => client.close());
const pushed = waitForClientMessage(client);
await client.communicator.send(create(TestDataSchema, { index: 12, message: "hello over wss" }));
await expect(pushed).resolves.toMatchObject({
index: 201,
message: "push from wss server",
});
});
test("WSS sendRequest/response roundtrip", async () => {
const server = new WsServer("127.0.0.1", 0, "/", (ws) => new WsClient(ws, 0, 0, parserMap()), {
cert,
key,
});
cleanup.push(async () => server.stop());
await server.start();
server.onClientConnected = (client) => {
addRequestListenerTyped(client.communicator, TestDataSchema, (req) =>
create(TestDataSchema, {
index: req.index * 2,
message: `wss echo: ${req.message}`,
}),
);
};
const client = await connectWss(
"127.0.0.1",
server.port,
"/",
{ ca: cert },
0,
0,
parserMap(),
);
cleanup.push(async () => client.close());
await expect(
sendRequestTyped(
client.communicator,
create(TestDataSchema, { index: 22, message: "request over wss" }),
TestDataSchema,
500,
),
).resolves.toMatchObject({
index: 44,
message: "wss echo: request over wss",
});
});
test("concurrent sendRequest/response roundtrip over WS", async () => {
const server = new WsServer("127.0.0.1", 0, "/", (ws) => new WsClient(ws, 0, 0, parserMap()));
cleanup.push(async () => server.stop());