update: crosstest coverage and proto restructure changes

This commit is contained in:
toki 2026-04-25 07:00:53 +09:00
parent 15d11f9ac5
commit 01de3f32bf
44 changed files with 3406 additions and 54 deletions

View file

@ -24,7 +24,7 @@
1. 새 언어 디렉터리를 만들기 전에 `agent-ops/skills/project/add-toki-socket-crosstest-language/templates/`의 문서를 복사해 패키지 README와 체크리스트 초안으로 사용한다.
2. `PROTOCOL.md``VERSIONING.md`의 현재 protocol version을 기준으로 지원 범위를 정한다.
3. canonical proto인 `dart/lib/src/packets/message_common.proto`에서 언어별 protobuf binding을 생성한다. Go처럼 generator 전용 option이 필요하면 언어 패키지 안에 copy를 둘 수 있지만, message schema는 `tools/check_proto_sync.sh`로 검증 가능해야 한다.
3. canonical proto인 `proto/message_common.proto`에서 언어별 protobuf binding을 생성한다. Go처럼 generator 전용 option이 필요하면 언어 패키지 안에 copy를 둘 수 있지만, message schema는 `tools/check_proto_sync.sh`로 검증 가능해야 한다.
4. `Transport``Communicator`를 먼저 구현하고, 그 위에 TCP/TLS+TCP/WS/WSS client/server를 올린다.
5. 같은 언어 단위 테스트를 작성한 뒤 `agent-ops/skills/project/add-toki-socket-crosstest-language/SKILL.md`의 runner 배치 규칙에 맞춰 양방향 crosstest를 추가한다.
6. README의 Implementations 표를 `Available`로 바꾸기 전에 formatter/linter, 단위 테스트, cross-language tests를 모두 통과시킨다.

View file

@ -187,7 +187,7 @@ Sending `HeartBeat {}`:
## Proto Source
`dart/lib/src/packets/message_common.proto` is the canonical packet definition.
All language implementations must keep the same message schema and generate bindings from it.
`proto/message_common.proto` is the canonical packet definition.
All language implementations generate bindings from it.
The Go implementation keeps a copy at `go/packets/message_common.proto` because Go generation needs `option go_package`. The Kotlin implementation keeps a copy at `kotlin/src/main/proto/message_common.proto` for Java/Kotlin generation options. Keep the message fields in sync with the canonical Dart proto before regenerating language bindings.
Language-specific generation options such as `go_package` and Java/Kotlin package options are kept in each language's own proto copy under `go/packets/` and `kotlin/src/main/proto/`. Keep those copies' message schemas in sync with `proto/message_common.proto` before regenerating language bindings.

View file

@ -84,14 +84,14 @@ await client.send(MyMessage()..text = 'hello');
## Adding Message Types
Edit the canonical proto at `dart/lib/src/packets/message_common.proto`, then regenerate all checked-in bindings:
Edit the canonical proto at `proto/message_common.proto`, then regenerate all checked-in bindings:
```bash
tools/generate_proto.sh
tools/check_proto_sync.sh
```
The Go and Kotlin proto copies are allowed to keep only language-specific options such as `option go_package` or Java package/class options. `tools/check_proto_sync.sh` fails with a diff when the message schema drifts.
The Go and Kotlin proto copies are allowed to keep only language-specific options such as `option go_package` or Java package/class options. `tools/check_proto_sync.sh` fails with a diff when their message schema drifts from `proto/message_common.proto`.
---

View file

@ -13,7 +13,7 @@ Toki Socket의 Dart/Flutter 구현체를 담당한다. ProtobufClient, ProtobufS
## 제외 경로
- `dart/lib/src/packets/` — protocol 도메인
- `dart/lib/src/packets/` — protocol 도메인의 Dart protobuf 생성 코드
## 주요 구성 요소
@ -34,4 +34,4 @@ Toki Socket의 Dart/Flutter 구현체를 담당한다. ProtobufClient, ProtobufS
## 금지 사항
- Dart 구현체에 Go/Kotlin 특화 로직을 추가하지 않는다
- proto 원본 파일 변경 시 proto 도메인 규칙을 먼저 따른다
- proto schema 변경 시 proto 도메인 규칙을 먼저 따른다

View file

@ -6,7 +6,8 @@
## 포함 경로
- `dart/lib/src/packets/` — proto 원본 및 Dart 생성 코드 (정식 원본)
- `proto/` — 언어 옵션 없는 proto 정식 원본
- `dart/lib/src/packets/` — Dart protobuf 생성 코드
- `go/packets/` — Go용 proto 복사본 (go_package 옵션만 추가)
- `kotlin/src/**/packets/` — Kotlin용 proto 복사본 (Java 패키지 옵션만 추가)
- `PROTOCOL.md` — 공식 와이어 포맷 명세
@ -19,12 +20,12 @@
## 주요 구성 요소
- `message_common.proto` — PacketBase, 메시지 타입 정의 (Dart 원본)
- `proto/message_common.proto` — PacketBase, 메시지 타입 정식 원본
- `PacketBase` — 모든 패킷의 공통 래퍼 (type_name, payload, correlation_id)
## 유지할 패턴
- proto 원본은 `dart/lib/src/packets/`에서만 편집
- proto 원본은 `proto/message_common.proto`에서만 편집
- Go/Kotlin proto는 언어별 옵션만 추가. 메시지 스키마는 건드리지 않는다
- 변경 후 반드시 `tools/generate_proto.sh` + `tools/check_proto_sync.sh` 실행
@ -35,5 +36,5 @@
## 금지 사항
- proto 파일을 Go/Kotlin 복사본에서 직접 편집하지 않는다 (메시지 스키마 변경은 Dart 원본에서만)
- proto 파일을 Go/Kotlin 복사본에서 직접 편집하지 않는다 (메시지 스키마 변경은 `proto/message_common.proto`에서만)
- PROTOCOL.md에 정의되지 않은 프레이밍 방식을 구현체에서 임의로 추가하지 않는다

View file

@ -38,7 +38,7 @@ VERSIONING.md — 프로토콜/패키지 버전 정책
## 프로젝트 특화 컨벤션
- proto 정의는 `dart/lib/src/packets/message_common.proto`가 정식 원본이다. Go/Kotlin은 언어별 옵션(go_package, java 패키지)만 추가 허용
- proto 정의는 `proto/message_common.proto`가 정식 원본이다. Go/Kotlin은 언어별 옵션(go_package, java 패키지)만 추가 허용
- proto 변경 시 반드시 `tools/generate_proto.sh` 실행 후 `tools/check_proto_sync.sh`로 동기화 확인
- 새 언어 구현은 PORTING_GUIDE.md에 따라 동일 언어 테스트 + 크로스 언어 테스트 통과 후 Available 표시
- 프로토콜 버전과 패키지 버전은 분리 관리 (VERSIONING.md 참조)
@ -49,6 +49,7 @@ VERSIONING.md — 프로토콜/패키지 버전 정책
| 경로 패턴 | 도메인 | rules.md |
|----------|--------|----------|
| `proto/**` | protocol | `agent-ops/rules/project/domain/protocol/rules.md` |
| `dart/lib/src/packets/**` | protocol | `agent-ops/rules/project/domain/protocol/rules.md` |
| `go/packets/**` | protocol | `agent-ops/rules/project/domain/protocol/rules.md` |
| `kotlin/src/**/packets/**` | protocol | `agent-ops/rules/project/domain/protocol/rules.md` |
@ -62,7 +63,7 @@ VERSIONING.md — 프로토콜/패키지 버전 정책
## 기타 경로
- `agent-task/<task-name>/` — 에이전트 작업 로그 (plan, code_review 등). 소스 코드 아님. 수정하지 않는다.
- `agent-task/<task-name>/` — 에이전트 작업 로그 (plan, code_review 등). 소스 코드 아님. PLAN.md 또는 CODE_REVIEW.md 파일을 읽고 작업하는 작업이 아닌이상. 수정하지 않는다.
## 스킬 라우팅

View file

@ -11,12 +11,12 @@ Use this package README to document the concrete files for:
- Transport abstraction: TCP, TLS+TCP, WS, and WSS implementations.
- Communicator core: parser map, listener routing, request-response correlation, and serialized writes.
- Client/server layer: connection lifecycle, close behavior, and heartbeat ownership.
- Generated protobuf bindings from `dart/lib/src/packets/message_common.proto`.
- Generated protobuf bindings from `proto/message_common.proto`.
- Unit tests and cross-language tests.
## Proto Generation
Document the package-local proto generation command here. The generated schema must match the canonical Dart proto except for language-specific generator options.
Document the package-local proto generation command here. The generated schema must match `proto/message_common.proto` except for language-specific generator options.
```bash
# <command to generate protobuf bindings>

View file

@ -0,0 +1,101 @@
<!-- task=api_consistency plan=0 tag=IMPROVE -->
# Code Review Reference - IMPROVE
## 개요
date=2026-04-24
task=api_consistency, plan=0, tag=IMPROVE
## 이 파일을 읽는 리뷰 에이전트에게
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
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` 스텁 작성.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [IMPROVE-1] Dart `sendRequest` timeout 파라미터 추가 | [x] |
| [IMPROVE-2] TypeScript write queue maxsize 제한 | [x] |
## 계획 대비 변경 사항
- TypeScript write loop는 첫 packet을 즉시 in-flight로 소비하므로, 테스트는 `1개 in-flight + 64개 queued` 상태에서 66번째 `queuePacket()`이 waiter에 들어가는 방식으로 검증했다.
- Dart `_pendingRequests` 정리 확인은 public API를 늘리지 않기 위해 테스트에서만 reflection으로 확인했다.
## 주요 설계 결정
- Dart `sendRequest`는 기존 호출 호환성을 유지하도록 named parameter 기본값 `Duration(seconds: 30)`을 사용했다.
- Dart timeout 발생 시 해당 nonce를 `_pendingRequests`에서 제거하고 `TimeoutException`을 전파한다.
- TypeScript write queue 제한은 queued item 수 기준 64개로 적용하고, queue item이 소비될 때 waiter를 하나씩 깨운다.
- TypeScript `shutdown()`은 대기 중인 sender를 모두 깨워 재개 시 `NotConnectedError`로 종료되도록 했다.
## 리뷰어를 위한 체크포인트
- `dart/lib/src/communicator.dart``sendRequest` 시그니처에 `{Duration timeout = const Duration(seconds: 30)}` 파라미터 있는지
- `dart/lib/src/communicator.dart` — timeout 발생 시 `_pendingRequests.remove(requestNonce)` 호출 후 `TimeoutException` 전파하는지
- `dart/test/communicator_test.dart``TimeoutException` 테스트 추가 및 통과 여부
- `typescript/src/communicator.ts``MAX_WRITE_QUEUE_SIZE = 64` 상수 존재 여부
- `typescript/src/communicator.ts``queuePacket()`에서 queue full 시 대기 로직 구현 여부
- `typescript/src/communicator.ts``shutdown()`에서 waiters 플러시 여부
- `typescript/src/communicator.ts``initialize()`에서 `writeQueueWaiters = []` 초기화 여부
- `typescript/test/communicator.test.ts` — 큐 full 대기 테스트 추가 및 통과 여부
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### IMPROVE-1 중간 검증
```
$ cd dart && dart test 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 cannot register addRequestListener for a type already using addListener
00:00 +4: Communicator protocol guards cannot register addListener for a type already using addRequestListener
00:00 +5: Communicator protocol guards queuePacket uses injected transport
00:00 +6: All tests passed!
$ cd dart && dart analyze
No issues found!
```
### IMPROVE-2 중간 검증
```
$ cd typescript && npx tsc --noEmit
(exit 0, no output)
$ cd typescript && npx vitest run test/communicator.test.ts
✓ test/communicator.test.ts (8 tests) 20ms
Test Files 1 passed (1)
Tests 8 passed (8)
```
### 최종 검증
```
$ cd dart && dart analyze
No issues found!
$ cd dart && dart test
00:15 +43: All tests passed!
$ cd typescript && npx tsc --noEmit
(exit 0, no output)
$ cd typescript && npx vitest run
✓ test/base_client.test.ts (7 tests) 9ms
✓ test/communicator.test.ts (8 tests) 19ms
✓ test/tcp.test.ts (2 tests) 13ms
✓ test/ws.test.ts (2 tests) 21ms
Test Files 4 passed (4)
Tests 19 passed (19)
```

View file

@ -0,0 +1,225 @@
<!-- task=api_consistency plan=0 tag=IMPROVE -->
# API 일관성 개선
## 이 파일을 읽는 구현 에이전트에게
각 항목의 체크리스트를 완료하면서 `[ ]``[x]`로 표시하라.
중간 검증 명령은 해당 항목 구현 직후 실행하고, 출력을 `CODE_REVIEW.md`의 검증 결과 섹션에 붙여넣어라.
최종 검증도 마찬가지로 실행 후 출력을 기록하라.
계획과 다르게 구현한 부분은 이유와 함께 `CODE_REVIEW.md`의 "계획 대비 변경 사항"에 기록하라.
---
## 배경
프로젝트 전반 평가에서 발견된 두 가지 API 비일관성을 수정한다.
| 항목 | 문제 | 영향 |
|------|------|------|
| IMPROVE-1 | Dart `sendRequest`에 timeout 파라미터 없음 | 응답 없으면 무한 대기 |
| IMPROVE-2 | TypeScript write queue 크기 제한 없음 | await 없이 대량 send 시 무한 증가 가능 |
Go/Kotlin/Python/TypeScript는 `sendRequest`에 timeout을 지원한다. Dart만 없다.
Go/Kotlin/Python은 write queue를 64개로 제한한다. TypeScript와 Dart는 제한이 없다.
Dart write queue는 Promise chaining 방식이라 구조상 추가가 어려우므로 이번 범위에서 제외한다.
## 의존 관계 및 구현 순서
IMPROVE-1과 IMPROVE-2는 독립적이다. 이 순서로 진행한다.
---
### [IMPROVE-1] Dart `sendRequest` timeout 파라미터 추가
**문제**
[dart/lib/src/communicator.dart](../../dart/lib/src/communicator.dart) — `sendRequest`에 timeout 파라미터가 없다.
```dart
Future<Res> sendRequest<Req extends GeneratedMessage, Res extends GeneratedMessage>(
Req data) async {
```
다른 언어 비교:
| 언어 | timeout 기본값 |
|------|--------------|
| Go | `time.Duration`, 기본 30s |
| Kotlin | `timeoutMs: Long = 30_000L` |
| Python | `timeout: float = 30.0` |
| TypeScript | `timeoutMs = 30000` |
| **Dart** | **없음 — 무한 대기** |
**해결 방법**
`sendRequest`에 named 파라미터 `{Duration timeout = const Duration(seconds: 30)}`를 추가한다.
응답 대기를 `completer.future.timeout(timeout, onTimeout: ...)` 으로 감싼다.
timeout 발생 시 `_pendingRequests`에서 해당 nonce를 제거하고 `TimeoutException`을 전파한다.
Before:
```dart
Future<Res> sendRequest<Req extends GeneratedMessage, Res extends GeneratedMessage>(
Req data) async {
if (!isAlive) return Future.error(StateError('not connected'));
final requestNonce = ++nonce;
// ...
return completer.future;
}
```
After:
```dart
Future<Res> sendRequest<Req extends GeneratedMessage, Res extends GeneratedMessage>(
Req data, {Duration timeout = const Duration(seconds: 30)}) async {
if (!isAlive) return Future.error(StateError('not connected'));
final requestNonce = ++nonce;
// ...
return completer.future.timeout(timeout, onTimeout: () {
_pendingRequests.remove(requestNonce);
throw TimeoutException(
'sendRequest timeout for nonce $requestNonce', timeout);
});
}
```
`dart:async``TimeoutException`을 사용한다 (`import 'dart:async'`는 이미 있음).
**수정 파일 및 체크리스트**
- [x] `dart/lib/src/communicator.dart``sendRequest``{Duration timeout = const Duration(seconds: 30)}` named 파라미터 추가
- [x] `dart/lib/src/communicator.dart``completer.future.timeout(timeout, onTimeout: ...)` 적용, pending 정리 포함
- [x] `dart/test/communicator_test.dart``sendRequest가 timeout 내 응답 없으면 TimeoutException을 던진다` 테스트 추가
**테스트 작성**
- 파일: `dart/test/communicator_test.dart`
- 테스트명: `sendRequest가 timeout 내 응답 없으면 TimeoutException을 던진다`
- 시나리오: `FakeTransport`에 packet을 보내지만 응답을 보내지 않음, `Duration(milliseconds: 50)` timeout으로 호출, `throwsA(isA<TimeoutException>())` 단언, timeout 후 `_pendingRequests`가 비어있는지 확인
**중간 검증**
```bash
cd dart && dart test test/communicator_test.dart
# 새 timeout 테스트 포함 전체 PASS
cd dart && dart analyze
# No issues found
```
---
### [IMPROVE-2] TypeScript write queue maxsize 제한
**문제**
[typescript/src/communicator.ts](../../typescript/src/communicator.ts) — write queue가 크기 제한 없는 `Array`다.
```typescript
private writeQueue: QueueItem[] = [];
```
Go/Kotlin/Python은 64개 제한 도달 시 큐에 빈 자리가 생길 때까지 발신을 블로킹한다.
TypeScript는 `await`하지 않고 대량 `send()`를 발행하면 큐가 무한 증가한다.
**해결 방법**
`MAX_WRITE_QUEUE_SIZE = 64` 상수를 추가한다.
큐가 가득 찼을 때 waiter Promise를 등록하고, 항목이 소비될 때마다 waiter를 깨운다 (Python의 `asyncio.Queue`와 동일한 시맨틱).
`shutdown()` 시 모든 waiter를 깨워 `NotConnectedError`로 종료한다.
추가할 멤버:
```typescript
private static readonly MAX_WRITE_QUEUE_SIZE = 64;
private writeQueueWaiters: Array<() => void> = [];
```
`queuePacket` 변경:
```typescript
async queuePacket(base: PacketBase): Promise<void> {
if (!this.isAliveFlag || this.transport === null) {
throw new NotConnectedError();
}
// 큐가 가득 찬 경우 빈 자리가 생길 때까지 대기
while (this.writeQueue.length >= Communicator.MAX_WRITE_QUEUE_SIZE) {
await new Promise<void>((resolve) => {
this.writeQueueWaiters.push(resolve);
});
if (!this.isAliveFlag) throw new NotConnectedError();
}
// 이하 기존 코드 동일
...
}
```
`runWriteQueue`에서 항목 소비 후 waiter 해제:
```typescript
const item = this.writeQueue.shift();
if (this.writeQueueWaiters.length > 0) {
this.writeQueueWaiters.shift()?.();
}
```
`shutdown()`에서 waiters 플러시:
```typescript
while (this.writeQueueWaiters.length > 0) {
this.writeQueueWaiters.shift()?.();
}
```
`initialize()`에서 `writeQueueWaiters` 초기화 추가:
```typescript
this.writeQueueWaiters = [];
```
**수정 파일 및 체크리스트**
- [x] `typescript/src/communicator.ts``MAX_WRITE_QUEUE_SIZE = 64` 상수 추가
- [x] `typescript/src/communicator.ts``writeQueueWaiters` 멤버 추가 및 `initialize()` 초기화
- [x] `typescript/src/communicator.ts``queuePacket()`에 full 시 대기 로직 추가
- [x] `typescript/src/communicator.ts``runWriteQueue()`에서 waiter 해제
- [x] `typescript/src/communicator.ts``shutdown()`에서 waiters 플러시
- [x] `typescript/test/communicator.test.ts` — 큐 full 시 대기 후 처리 테스트 추가
**테스트 작성**
- 파일: `typescript/test/communicator.test.ts`
- 테스트명: `write queue가 MAX_WRITE_QUEUE_SIZE 초과 시 대기 후 순서대로 처리된다`
- 시나리오: FakeTransport에서 write를 인위적으로 지연, 65번째 `queuePacket` 호출이 블로킹되다가 64번째가 처리되면 진행됨을 확인
**중간 검증**
```bash
cd typescript && npx tsc --noEmit
# 타입 오류 없음
cd typescript && npx vitest run test/communicator.test.ts
# 새 큐 테스트 포함 PASS
```
---
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `dart/lib/src/communicator.dart` | IMPROVE-1 |
| `dart/test/communicator_test.dart` | IMPROVE-1 |
| `typescript/src/communicator.ts` | IMPROVE-2 |
| `typescript/test/communicator.test.ts` | IMPROVE-2 |
## 최종 검증
```bash
# Dart
cd dart && dart analyze
# No issues found
cd dart && dart test
# 전체 PASS (신규 timeout 테스트 포함)
# TypeScript
cd typescript && npx tsc --noEmit
# 타입 오류 없음
cd typescript && npx vitest run
# 전체 PASS (신규 큐 테스트 포함)
```

View file

@ -0,0 +1,101 @@
<!-- task=api_consistency plan=0 tag=IMPROVE -->
# Code Review Reference - IMPROVE
## 개요
date=2026-04-24
task=api_consistency, plan=0, tag=IMPROVE
## 이 파일을 읽는 리뷰 에이전트에게
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
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` 스텁 작성.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [IMPROVE-1] Dart `sendRequest` timeout 파라미터 추가 | [x] |
| [IMPROVE-2] TypeScript write queue maxsize 제한 | [x] |
## 계획 대비 변경 사항
- TypeScript write loop는 첫 packet을 즉시 in-flight로 소비하므로, 테스트는 `1개 in-flight + 64개 queued` 상태에서 66번째 `queuePacket()`이 waiter에 들어가는 방식으로 검증했다.
- Dart `_pendingRequests` 정리 확인은 public API를 늘리지 않기 위해 테스트에서만 reflection으로 확인했다.
## 주요 설계 결정
- Dart `sendRequest`는 기존 호출 호환성을 유지하도록 named parameter 기본값 `Duration(seconds: 30)`을 사용했다.
- Dart timeout 발생 시 해당 nonce를 `_pendingRequests`에서 제거하고 `TimeoutException`을 전파한다.
- TypeScript write queue 제한은 queued item 수 기준 64개로 적용하고, queue item이 소비될 때 waiter를 하나씩 깨운다.
- TypeScript `shutdown()`은 대기 중인 sender를 모두 깨워 재개 시 `NotConnectedError`로 종료되도록 했다.
## 리뷰어를 위한 체크포인트
- `dart/lib/src/communicator.dart` — `sendRequest` 시그니처에 `{Duration timeout = const Duration(seconds: 30)}` 파라미터 있는지
- `dart/lib/src/communicator.dart` — timeout 발생 시 `_pendingRequests.remove(requestNonce)` 호출 후 `TimeoutException` 전파하는지
- `dart/test/communicator_test.dart` — `TimeoutException` 테스트 추가 및 통과 여부
- `typescript/src/communicator.ts` — `MAX_WRITE_QUEUE_SIZE = 64` 상수 존재 여부
- `typescript/src/communicator.ts` — `queuePacket()`에서 queue full 시 대기 로직 구현 여부
- `typescript/src/communicator.ts` — `shutdown()`에서 waiters 플러시 여부
- `typescript/src/communicator.ts` — `initialize()`에서 `writeQueueWaiters = []` 초기화 여부
- `typescript/test/communicator.test.ts` — 큐 full 대기 테스트 추가 및 통과 여부
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### IMPROVE-1 중간 검증
```
$ cd dart && dart test 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 cannot register addRequestListener for a type already using addListener
00:00 +4: Communicator protocol guards cannot register addListener for a type already using addRequestListener
00:00 +5: Communicator protocol guards queuePacket uses injected transport
00:00 +6: All tests passed!
$ cd dart && dart analyze
No issues found!
```
### IMPROVE-2 중간 검증
```
$ cd typescript && npx tsc --noEmit
(exit 0, no output)
$ cd typescript && npx vitest run test/communicator.test.ts
✓ test/communicator.test.ts (8 tests) 20ms
Test Files 1 passed (1)
Tests 8 passed (8)
```
### 최종 검증
```
$ cd dart && dart analyze
No issues found!
$ cd dart && dart test
00:15 +43: All tests passed!
$ cd typescript && npx tsc --noEmit
(exit 0, no output)
$ cd typescript && npx vitest run
✓ test/base_client.test.ts (7 tests) 9ms
✓ test/communicator.test.ts (8 tests) 19ms
✓ test/tcp.test.ts (2 tests) 13ms
✓ test/ws.test.ts (2 tests) 21ms
Test Files 4 passed (4)
Tests 19 passed (19)
```

View file

@ -0,0 +1,15 @@
task=api_consistency
date=2026-04-25
result=PASS
## 완료된 작업 (plan=0)
| 항목 | 내용 |
|------|------|
| IMPROVE-1 | Dart `sendRequest`에 `{Duration timeout = const Duration(seconds: 30)}` named 파라미터 추가. timeout 발생 시 pending 정리 + `TimeoutException` 전파. `test_communicator_test.dart`에 reflection 기반 timeout 테스트 추가 |
| IMPROVE-2 | TypeScript `Communicator`에 `MAX_WRITE_QUEUE_SIZE = 64` 제한 추가. waiter 패턴으로 queue full 시 대기 후 처리. `shutdown()` 시 waiters 플러시. `communicator.test.ts`에 `BlockingTransport` 기반 큐 포화 테스트 추가 |
## 최종 상태
- Dart: dart analyze No issues / dart test 43개 PASS (+1 timeout 테스트)
- TypeScript: tsc --noEmit clean / vitest 19개 PASS (+1 큐 포화 테스트)

View file

@ -0,0 +1,225 @@
<!-- task=api_consistency plan=0 tag=IMPROVE -->
# API 일관성 개선
## 이 파일을 읽는 구현 에이전트에게
각 항목의 체크리스트를 완료하면서 `[ ]`를 `[x]`로 표시하라.
중간 검증 명령은 해당 항목 구현 직후 실행하고, 출력을 `CODE_REVIEW.md`의 검증 결과 섹션에 붙여넣어라.
최종 검증도 마찬가지로 실행 후 출력을 기록하라.
계획과 다르게 구현한 부분은 이유와 함께 `CODE_REVIEW.md`의 "계획 대비 변경 사항"에 기록하라.
---
## 배경
프로젝트 전반 평가에서 발견된 두 가지 API 비일관성을 수정한다.
| 항목 | 문제 | 영향 |
|------|------|------|
| IMPROVE-1 | Dart `sendRequest`에 timeout 파라미터 없음 | 응답 없으면 무한 대기 |
| IMPROVE-2 | TypeScript write queue 크기 제한 없음 | await 없이 대량 send 시 무한 증가 가능 |
Go/Kotlin/Python/TypeScript는 `sendRequest`에 timeout을 지원한다. Dart만 없다.
Go/Kotlin/Python은 write queue를 64개로 제한한다. TypeScript와 Dart는 제한이 없다.
Dart write queue는 Promise chaining 방식이라 구조상 추가가 어려우므로 이번 범위에서 제외한다.
## 의존 관계 및 구현 순서
IMPROVE-1과 IMPROVE-2는 독립적이다. 이 순서로 진행한다.
---
### [IMPROVE-1] Dart `sendRequest` timeout 파라미터 추가
**문제**
[dart/lib/src/communicator.dart](../../dart/lib/src/communicator.dart) — `sendRequest`에 timeout 파라미터가 없다.
```dart
Future<Res> sendRequest<Req extends GeneratedMessage, Res extends GeneratedMessage>(
Req data) async {
```
다른 언어 비교:
| 언어 | timeout 기본값 |
|------|--------------|
| Go | `time.Duration`, 기본 30s |
| Kotlin | `timeoutMs: Long = 30_000L` |
| Python | `timeout: float = 30.0` |
| TypeScript | `timeoutMs = 30000` |
| **Dart** | **없음 — 무한 대기** |
**해결 방법**
`sendRequest`에 named 파라미터 `{Duration timeout = const Duration(seconds: 30)}`를 추가한다.
응답 대기를 `completer.future.timeout(timeout, onTimeout: ...)` 으로 감싼다.
timeout 발생 시 `_pendingRequests`에서 해당 nonce를 제거하고 `TimeoutException`을 전파한다.
Before:
```dart
Future<Res> sendRequest<Req extends GeneratedMessage, Res extends GeneratedMessage>(
Req data) async {
if (!isAlive) return Future.error(StateError('not connected'));
final requestNonce = ++nonce;
// ...
return completer.future;
}
```
After:
```dart
Future<Res> sendRequest<Req extends GeneratedMessage, Res extends GeneratedMessage>(
Req data, {Duration timeout = const Duration(seconds: 30)}) async {
if (!isAlive) return Future.error(StateError('not connected'));
final requestNonce = ++nonce;
// ...
return completer.future.timeout(timeout, onTimeout: () {
_pendingRequests.remove(requestNonce);
throw TimeoutException(
'sendRequest timeout for nonce $requestNonce', timeout);
});
}
```
`dart:async`의 `TimeoutException`을 사용한다 (`import 'dart:async'`는 이미 있음).
**수정 파일 및 체크리스트**
- [x] `dart/lib/src/communicator.dart` — `sendRequest`에 `{Duration timeout = const Duration(seconds: 30)}` named 파라미터 추가
- [x] `dart/lib/src/communicator.dart` — `completer.future.timeout(timeout, onTimeout: ...)` 적용, pending 정리 포함
- [x] `dart/test/communicator_test.dart` — `sendRequest가 timeout 내 응답 없으면 TimeoutException을 던진다` 테스트 추가
**테스트 작성**
- 파일: `dart/test/communicator_test.dart`
- 테스트명: `sendRequest가 timeout 내 응답 없으면 TimeoutException을 던진다`
- 시나리오: `FakeTransport`에 packet을 보내지만 응답을 보내지 않음, `Duration(milliseconds: 50)` timeout으로 호출, `throwsA(isA<TimeoutException>())` 단언, timeout 후 `_pendingRequests`가 비어있는지 확인
**중간 검증**
```bash
cd dart && dart test test/communicator_test.dart
# 새 timeout 테스트 포함 전체 PASS
cd dart && dart analyze
# No issues found
```
---
### [IMPROVE-2] TypeScript write queue maxsize 제한
**문제**
[typescript/src/communicator.ts](../../typescript/src/communicator.ts) — write queue가 크기 제한 없는 `Array`다.
```typescript
private writeQueue: QueueItem[] = [];
```
Go/Kotlin/Python은 64개 제한 도달 시 큐에 빈 자리가 생길 때까지 발신을 블로킹한다.
TypeScript는 `await`하지 않고 대량 `send()`를 발행하면 큐가 무한 증가한다.
**해결 방법**
`MAX_WRITE_QUEUE_SIZE = 64` 상수를 추가한다.
큐가 가득 찼을 때 waiter Promise를 등록하고, 항목이 소비될 때마다 waiter를 깨운다 (Python의 `asyncio.Queue`와 동일한 시맨틱).
`shutdown()` 시 모든 waiter를 깨워 `NotConnectedError`로 종료한다.
추가할 멤버:
```typescript
private static readonly MAX_WRITE_QUEUE_SIZE = 64;
private writeQueueWaiters: Array<() => void> = [];
```
`queuePacket` 변경:
```typescript
async queuePacket(base: PacketBase): Promise<void> {
if (!this.isAliveFlag || this.transport === null) {
throw new NotConnectedError();
}
// 큐가 가득 찬 경우 빈 자리가 생길 때까지 대기
while (this.writeQueue.length >= Communicator.MAX_WRITE_QUEUE_SIZE) {
await new Promise<void>((resolve) => {
this.writeQueueWaiters.push(resolve);
});
if (!this.isAliveFlag) throw new NotConnectedError();
}
// 이하 기존 코드 동일
...
}
```
`runWriteQueue`에서 항목 소비 후 waiter 해제:
```typescript
const item = this.writeQueue.shift();
if (this.writeQueueWaiters.length > 0) {
this.writeQueueWaiters.shift()?.();
}
```
`shutdown()`에서 waiters 플러시:
```typescript
while (this.writeQueueWaiters.length > 0) {
this.writeQueueWaiters.shift()?.();
}
```
`initialize()`에서 `writeQueueWaiters` 초기화 추가:
```typescript
this.writeQueueWaiters = [];
```
**수정 파일 및 체크리스트**
- [x] `typescript/src/communicator.ts` — `MAX_WRITE_QUEUE_SIZE = 64` 상수 추가
- [x] `typescript/src/communicator.ts` — `writeQueueWaiters` 멤버 추가 및 `initialize()` 초기화
- [x] `typescript/src/communicator.ts` — `queuePacket()`에 full 시 대기 로직 추가
- [x] `typescript/src/communicator.ts` — `runWriteQueue()`에서 waiter 해제
- [x] `typescript/src/communicator.ts` — `shutdown()`에서 waiters 플러시
- [x] `typescript/test/communicator.test.ts` — 큐 full 시 대기 후 처리 테스트 추가
**테스트 작성**
- 파일: `typescript/test/communicator.test.ts`
- 테스트명: `write queue가 MAX_WRITE_QUEUE_SIZE 초과 시 대기 후 순서대로 처리된다`
- 시나리오: FakeTransport에서 write를 인위적으로 지연, 65번째 `queuePacket` 호출이 블로킹되다가 64번째가 처리되면 진행됨을 확인
**중간 검증**
```bash
cd typescript && npx tsc --noEmit
# 타입 오류 없음
cd typescript && npx vitest run test/communicator.test.ts
# 새 큐 테스트 포함 PASS
```
---
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `dart/lib/src/communicator.dart` | IMPROVE-1 |
| `dart/test/communicator_test.dart` | IMPROVE-1 |
| `typescript/src/communicator.ts` | IMPROVE-2 |
| `typescript/test/communicator.test.ts` | IMPROVE-2 |
## 최종 검증
```bash
# Dart
cd dart && dart analyze
# No issues found
cd dart && dart test
# 전체 PASS (신규 timeout 테스트 포함)
# TypeScript
cd typescript && npx tsc --noEmit
# 타입 오류 없음
cd typescript && npx vitest run
# 전체 PASS (신규 큐 테스트 포함)
```

View file

@ -0,0 +1,187 @@
<!-- task=crosstest_coverage_gaps plan=0 tag=CROSSTEST-COVERAGE -->
# Code Review Reference - CROSSTEST-COVERAGE
## 개요
date=2026-04-24
task=crosstest_coverage_gaps, plan=0, tag=CROSSTEST-COVERAGE
## 이 파일을 읽는 리뷰 에이전트에게
각 항목의 구현을 실제 소스 파일과 대조하고, 검증 결과가 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
1. `CODE_REVIEW.md` -> `code_review_0.log`
2. `PLAN.md` -> `plan_0.log`
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|-----------|
| [SELF-1] Self-cell coverage audit 표준화 | [x] |
| [SELF-2] Go self-cell 동시 request-response 보강 | [x] |
| [SELF-3] Kotlin self-cell 동시 request-response 보강 | [x] |
| [SELF-4] Python self-cell TCP/WS scenario 보강 | [x] |
| [SELF-5] TypeScript self-cell 동시 request-response 보강 | [x] |
| [SECURE-1] Dart <-> Go secure crosstest 추가 | [x] |
| [SECURE-2] Kotlin/Python/TypeScript secure matrix 보류 사유 문서화 | [x] |
## 계획 대비 변경 사항
- Python `test_tcp.py`에 `_wait_for_clients` 헬퍼 추가 (broadcast 전 클라이언트 연결 대기). WS 테스트에도 동일 패턴 적용.
- `go/crosstest/go_dart.go`의 `runDartClient`를 variadic `certFile ...string` 형식으로 변경 — 기존 TCP/WS 호출은 영향 없음.
- `go/crosstest/go_dart.go`에 `loadServerTLS` 헬퍼 추가.
## 주요 설계 결정
- **공유 인증서**: `dart/test/certs/server.crt` / `.key` 재사용. SAN에 `IP:127.0.0.1`과 `DNS:localhost` 모두 포함되어 있어 Go/Dart 양쪽 검증 통과 확인.
- **인증서 경로 전달**: Dart 오케스트레이터(dart_go.dart)와 Go 오케스트레이터(go_dart.go)가 각자 cert 경로를 계산하여 서브프로세스 클라이언트에 `--cert=<path>` 인수로 전달. machine-global trust store 미사용.
- **Go 서버 TLS**: `toki.NewTcpServerTLS` / `toki.NewWsServerTLS`에 `tls.LoadX509KeyPair`로 로드한 config 전달.
- **Dart 클라이언트 TLS**: `ProtobufClient.connectSecure` / `WsProtobufClient.connectSecure`에 `SecurityContext()..setTrustedCertificates(certPath)` 전달.
- **고정 포트**: TLS+TCP 29094/29194, WSS 29096/29196 (기존 plain 포트 29090/29092/29190/29192와 충돌 없음).
## Self-cell 감사 결과
시나리오 정의: 1=클라이언트→서버 fire-and-forget, 2=서버→클라이언트 push, 3=단일 request-response, 4=다중 동시 request-response
| Language | TCP scenario 1/2 | TCP scenario 3 | TCP scenario 4 | WS scenario 1/2 | WS scenario 3 | WS scenario 4 | 조치 |
|----------|------------------|----------------|----------------|-----------------|---------------|---------------|------|
| Dart | ✓ (socket_test.dart) | ✓ | ✓ (Future.wait) | ✓ | ✓ | ✓ (Future.wait) | 소스 변경 없음 → UT-ok |
| Go | ✓ (tcp_test.go) | ✓ | 추가됨 (TestTcpConcurrentRequests) | ✓ (ws_test.go) | ✓ | 추가됨 (TestWsConcurrentRequests) | SELF-2 적용 |
| Kotlin | ✓ (TcpTest.kt) | ✓ | 추가됨 (testTcpConcurrentRequests) | ✓ (WsTest.kt) | ✓ | 추가됨 (testWsConcurrentRequests) | SELF-3 적용 |
| Python | ✓ (test_tcp.py) | ✓ | 추가됨 (test_tcp_concurrent_requests) | 신규 파일 생성 (test_ws.py) | 추가됨 | 추가됨 | SELF-4 적용 |
| TypeScript | ✓ (tcp.test.ts/ws.test.ts) | ✓ | 추가됨 (concurrent roundtrip) | ✓ | ✓ | 추가됨 (concurrent roundtrip) | SELF-5 적용 |
**Python test_ws.py 신규 생성 사유**: Python에는 WS 테스트 파일 자체가 없었음. `tcp_server.py`와 대칭으로 `ws_server.py`/`ws_client.py`가 구현 완료되어 있어 `test_ws.py`가 자연스러운 위치.
## Secure Matrix 결과
| Server \ Client | Dart | Go | Kotlin | Python | TypeScript |
|-----------------|------|----|--------|--------|------------|
| Dart | 기존 단위 테스트 (socket_test.dart) | CT-secure (dart_go.dart) | deferred | deferred | deferred |
| Go | CT-secure (go_dart.go) | 기존 단위 테스트 (tls_test.go) | deferred | deferred | deferred |
| Kotlin | deferred | deferred | deferred | deferred | deferred |
| Python | deferred | deferred | deferred | deferred | deferred |
| TypeScript | deferred | deferred | deferred | deferred | deferred |
### Kotlin/Python/TypeScript secure 보류 사유
| 언어 | TLS+TCP 서버 | TLS+TCP 클라이언트 | WSS 서버 | WSS 클라이언트 | 보류 사유 |
|------|------------|-----------------|---------|--------------|---------|
| Kotlin | 없음 | 없음 | 없음 | 있음 (`wss://` URL 지원) | TLS+TCP API 및 WSS 서버 API 미구현. cross-language secure matrix 적용 불가. |
| Python | 없음 | 없음 | 없음 | 없음 | `tcp_server.py`/`tcp_client.py`/`ws_server.py`/`ws_client.py` 모두 SSL context 경로 없음. |
| TypeScript | 없음 | 없음 | 없음 | 없음 | `net` 모듈 plain TCP, plain `ws://`만 사용. `tls`/`https` wrapper 미구현. |
구현 자체는 이 계획의 범위 밖. README 과잉 주장 없음 — 언어별 README 수정 불필요.
## 리뷰어를 위한 체크포인트
- 기존 cross-language TCP/WS 20개 directed 조합을 삭제하거나 약화하지 않았는지 확인
- self-cell 보강 테스트가 scenario 1-4의 의미와 맞는지 확인
- Python WS 테스트가 Python server/client 둘 다 실제 `WsServer`/`connect_ws`를 사용하는지 확인
- Go/Kotlin/TypeScript concurrent request 테스트가 응답 순서가 아니라 nonce/responseNonce 매핑 결과를 검증하는지 확인
- Dart <-> Go secure crosstest가 machine-global trust store에 의존하지 않는지 확인
- Kotlin/Python/TypeScript secure deferred 사유가 실제 API 지원 상태와 일치하는지 확인
## 검증 결과
### Self-cell 보강 검증
```
$ cd dart && dart test
$ cd go && go test ./...
$ cd kotlin && ./gradlew test
$ cd python && python3 -m pytest test/ -v
$ cd typescript && npm test
$ cd typescript && npm run check
```
### Dart/Go secure crosstest 검증
```
$ cd dart && dart run crosstest/dart_go.dart
$ cd go && go run ./crosstest/go_dart.go
```
### Cross-language 회귀 검증
```
$ cd go && go run ./crosstest/go_kotlin.go
$ cd go && go run ./crosstest/go_python.go
$ cd go && go run ./crosstest/go_typescript.go
$ cd dart && dart run crosstest/dart_kotlin.dart
$ cd dart && dart run crosstest/dart_python.dart
$ cd dart && dart run crosstest/dart_typescript.dart
$ cd kotlin && ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinGoKt
$ cd kotlin && ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinPythonKt
$ cd kotlin && ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinTypescriptKt
$ cd python && python3 -m crosstest.python_dart
$ cd python && python3 -m crosstest.python_go
$ cd python && python3 -m crosstest.python_kotlin
$ cd python && python3 -m crosstest.python_typescript
$ cd typescript && node --import tsx crosstest/typescript_dart.ts
$ cd typescript && node --import tsx crosstest/typescript_go.ts
$ cd typescript && node --import tsx crosstest/typescript_kotlin.ts
$ cd typescript && node --import tsx crosstest/typescript_python.ts
```
### 정리 검증
```
$ git diff --check
```
---
## 코드리뷰 결과
### 종합 판정
FAIL
### 차원별 평가
| 차원 | 평가 | 근거 |
|------|------|------|
| correctness | Fail | Kotlin 전체 테스트가 재현 가능하게 완료되지 않아 SELF-3 결과를 신뢰할 수 없음 |
| completeness | Fail | `CODE_REVIEW.md`에 기록된 `cd kotlin && ./gradlew test` 검증과 실제 재검증 결과가 불일치 |
| test coverage | Warn | 추가된 Kotlin concurrent 테스트 단독 실행은 통과하지만 전체 suite 통과 근거가 없음 |
| API contract | Pass | Dart/Go secure crosstest 및 Python/TypeScript self-cell 보강은 기존 API 계약과 일치 |
| code quality | Warn | Kotlin 신규 테스트 cleanup이 `try/finally`가 아니어서 실패 시 서버/클라이언트 누수 가능 |
| plan deviation | Pass | 계획된 변경 범위 자체는 대체로 유지됨 |
| verification trust | Fail | `./gradlew test` 통과 기록을 재현하지 못함 |
### 발견된 문제
- Required: `kotlin/src/test/kotlin/com/tokilabs/toki_socket/TcpTest.kt:16` - `timeout 90s ./gradlew test`가 daemon 재시작 후에도 `:test` 단계에서 타임아웃됨. jstack 기준 `TcpTest.testTcpSendReceive`의 `received.await()`에서 대기 중이며, `CODE_REVIEW.md`의 Kotlin 전체 테스트 통과 검증과 불일치한다. 전체 suite가 안정적으로 통과하도록 원인을 수정하고 `./gradlew test`를 재검증해야 한다.
- Required: `kotlin/src/test/kotlin/com/tokilabs/toki_socket/TcpTest.kt:131` / `kotlin/src/test/kotlin/com/tokilabs/toki_socket/WsTest.kt:116` - 새 concurrent 테스트가 `awaitAll()` 이후에만 `client.close()`와 `server.stop()`을 호출한다. 요청 실패/타임아웃 시 cleanup이 건너뛰어 후속 테스트 hang을 유발할 수 있으므로 `try/finally`로 정리 보장을 추가해야 한다.
### 다음 단계
FAIL: Required 이슈를 해결하는 새 `PLAN.md`와 `CODE_REVIEW.md` 스텁을 작성해 리뷰 루프를 계속한다.

View file

@ -0,0 +1,99 @@
<!-- task=crosstest_coverage_gaps plan=1 tag=REVIEW_CROSSTEST-COVERAGE -->
# Code Review Reference - REVIEW_CROSSTEST-COVERAGE
## 개요
date=2026-04-25
task=crosstest_coverage_gaps, plan=1, tag=REVIEW_CROSSTEST-COVERAGE
## 이 파일을 읽는 리뷰 에이전트에게
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
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` 스텁 작성.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_CROSSTEST-COVERAGE-1] Kotlin 전체 test suite hang 원인 수정 | [x] |
| [REVIEW_CROSSTEST-COVERAGE-2] Kotlin concurrent 테스트 cleanup 보장 | [x] |
## 계획 대비 변경 사항
- REVIEW_CROSSTEST-COVERAGE-1에서 `testWsSendReceive`에도 동일한 `waitForCondition` 추가. 계획은 TCP만 언급했으나 WS 테스트도 동일 레이스 구조를 가지므로 함께 수정.
- 메서드 단위 Gradle 필터(`--tests *.testTcpConcurrentRequests`)가 해당 환경에서 동작하지 않아, 검증은 클래스 단위(`--tests com.tokilabs.toki_socket.TcpTest`) 및 전체 suite로 대체.
## 주요 설계 결정
- **`waitForCondition` 추가 위치**: `testTcpSendReceive` / `testWsSendReceive`에서 `DialTcp`/`DialWs` 직후, `client.send()` 직전에 삽입. `onClientConnected` 콜백이 listener를 등록하기 전에 send가 호출되는 레이스를 차단.
- **`try/finally` 범위**: concurrent 테스트 두 곳(`testTcpConcurrentRequests`, `testWsConcurrentRequests`)만 변경. 계획이 명시한 범위 외 리팩터링은 하지 않음.
## 리뷰어를 위한 체크포인트
- `./gradlew test`가 timeout 없이 완료되는지 확인
- `TcpTest.testTcpConcurrentRequests` / `WsTest.testWsConcurrentRequests`가 실패 경로에서도 cleanup을 보장하는지 확인
- Kotlin self-cell scenario 1-4 검증 의미가 약화되지 않았는지 확인
- plan=0에서 통과한 Dart/Go secure crosstest 및 Python/TypeScript 변경을 불필요하게 건드리지 않았는지 확인
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### REVIEW_CROSSTEST-COVERAGE-1 중간 검증
```
$ cd kotlin && ./gradlew test --tests com.tokilabs.toki_socket.TcpTest
BUILD SUCCESSFUL in 24s
```
### REVIEW_CROSSTEST-COVERAGE-2 중간 검증
```
$ cd kotlin && ./gradlew test --tests com.tokilabs.toki_socket.TcpTest
BUILD SUCCESSFUL in 13s
$ cd kotlin && ./gradlew test --tests com.tokilabs.toki_socket.WsTest
BUILD SUCCESSFUL in 4s
(참고: 해당 환경에서 메서드 단위 --tests 필터가 동작하지 않아 클래스 단위로 대체)
```
### 최종 검증
```
$ cd kotlin && ./gradlew test
BUILD SUCCESSFUL in 12s
$ git diff --check
(출력 없음 — whitespace 이슈 없음)
```
---
## 코드리뷰 결과
### 종합 판정
FAIL
### 차원별 평가
| 차원 | 평가 | 근거 |
|------|------|------|
| correctness | Fail | `server.clients().size == 1` 대기는 `onClientConnected` 내부 listener/request handler 등록 완료를 보장하지 못해 원래 race가 남아 있음 |
| completeness | Fail | REVIEW_CROSSTEST-COVERAGE-1의 "listener 등록 전 send 호출 레이스 차단" 목표가 충분히 충족되지 않음 |
| test coverage | Pass | Kotlin TCP/WS 클래스 테스트와 전체 `./gradlew test`는 재검증 통과 |
| API contract | Pass | 공개 API/프로덕션 구현 변경 없이 테스트 범위 안에서 수정됨 |
| code quality | Pass | concurrent 테스트 cleanup은 `try/finally`로 보강됨 |
| plan deviation | Pass | WS send/receive 동일 레이스 보강은 계획 범위 안의 합리적 확장 |
| verification trust | Warn | 검증 명령은 통과했지만 현재 assertion/대기 조건이 race 제거를 직접 검증하지는 않음 |
### 발견된 문제
- Required: `kotlin/src/test/kotlin/com/tokilabs/toki_socket/TcpTest.kt:28` / `kotlin/src/test/kotlin/com/tokilabs/toki_socket/WsTest.kt:26` - `waitForCondition { server.clients().size == 1 }`는 연결 객체가 서버 목록에 들어간 것만 확인한다. 실제 서버 구현은 `clients.add(client)` 이후 `onClientConnected(client)`를 호출하므로, 테스트가 깨어난 시점에 `addListenerTyped`가 아직 실행되지 않았을 수 있다. `CompletableDeferred<Unit>` 같은 readiness 신호를 `onClientConnected`에서 listener 등록 직후 complete하고, 테스트는 그 신호를 await한 뒤 send/request를 시작해야 한다.
- Required: `kotlin/src/test/kotlin/com/tokilabs/toki_socket/TcpTest.kt:117` / `kotlin/src/test/kotlin/com/tokilabs/toki_socket/WsTest.kt:102` - 새 concurrent request 테스트도 클라이언트 연결 직후 요청을 시작하며, request listener 등록 완료를 기다리지 않는다. 동일하게 `onClientConnected`에서 request handler 등록 완료 신호를 내보내고, `awaitAll()` 시작 전에 해당 신호를 await해야 한다.
### 다음 단계
FAIL: Required 이슈를 해결하는 새 `PLAN.md`와 `CODE_REVIEW.md` 스텁을 작성해 리뷰 루프를 계속한다.

View file

@ -0,0 +1,95 @@
<!-- task=crosstest_coverage_gaps plan=2 tag=REVIEW_REVIEW_CROSSTEST-COVERAGE -->
# Code Review Reference - REVIEW_REVIEW_CROSSTEST-COVERAGE
## 개요
date=2026-04-25
task=crosstest_coverage_gaps, plan=2, tag=REVIEW_REVIEW_CROSSTEST-COVERAGE
## 이 파일을 읽는 리뷰 에이전트에게
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
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` 스텁 작성.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REVIEW_CROSSTEST-COVERAGE-1] Kotlin 테스트 listener/request handler readiness 보장 | [x] |
## 계획 대비 변경 사항
없음. 계획과 동일하게 구현함.
## 주요 설계 결정
- `testTcpSendReceive` / `testWsSendReceive`: `val listenerReady = CompletableDeferred<Unit>()`을 선언하고, `onClientConnected` 안에서 `addListenerTyped` 직후 `listenerReady.complete(Unit)` 호출. 기존 `waitForCondition { server.clients().size == 1 }`을 `listenerReady.await()`으로 교체.
- `testTcpConcurrentRequests` / `testWsConcurrentRequests`: `val handlerReady = CompletableDeferred<Unit>()`을 선언하고, `onClientConnected` 안에서 `addRequestListenerTyped` 블록 이후 `handlerReady.complete(Unit)` 호출. 요청 시작(`map { async { ... } }`) 직전에 `handlerReady.await()` 삽입.
- concurrent 테스트의 `try/finally` cleanup(`client.close()` / `server.stop()`)은 그대로 유지.
- production 코드(`TcpServer`, `WsServer`, `TcpClient`, `WsClient` 등) 변경 없음.
## 리뷰어를 위한 체크포인트
- `testTcpSendReceive` / `testWsSendReceive`가 listener 등록 완료 신호를 await한 뒤 send하는지 확인
- `testTcpConcurrentRequests` / `testWsConcurrentRequests`가 request listener 등록 완료 신호를 await한 뒤 요청을 시작하는지 확인
- concurrent 테스트의 `try/finally` cleanup이 유지되는지 확인
- Kotlin production server/client 동작이 불필요하게 변경되지 않았는지 확인
- `./gradlew test`가 timeout 없이 완료되는지 확인
## 검증 결과
### REVIEW_REVIEW_CROSSTEST-COVERAGE-1 중간 검증
```
$ cd kotlin && ./gradlew test --tests com.tokilabs.toki_socket.TcpTest
BUILD SUCCESSFUL in 6s
10 actionable tasks: 2 executed, 8 up-to-date
$ cd kotlin && ./gradlew test --tests com.tokilabs.toki_socket.WsTest
BUILD SUCCESSFUL in 4s
10 actionable tasks: 1 executed, 9 up-to-date
```
### 최종 검증
```
$ cd kotlin && ./gradlew test
BUILD SUCCESSFUL in 12s
10 actionable tasks: 1 executed, 9 up-to-date
$ git diff --check
(출력 없음 — whitespace 이상 없음)
```
---
## 코드리뷰 결과
### 종합 판정
PASS
### 차원별 평가
| 차원 | 평가 | 근거 |
|------|------|------|
| correctness | Pass | listener/request handler 등록 완료 신호를 await한 뒤 send/request를 시작함 |
| completeness | Pass | plan=2 체크리스트의 TCP/WS send 및 concurrent request readiness 보장이 모두 구현됨 |
| test coverage | Pass | `TcpTest`, `WsTest`, 전체 `./gradlew test` 재검증 통과 |
| API contract | Pass | Kotlin production server/client 코드는 변경하지 않고 테스트 안정성만 보강함 |
| code quality | Pass | concurrent 테스트의 `try/finally` cleanup이 유지됨 |
| plan deviation | Pass | 계획 대비 추가 변경 없음 |
| verification trust | Pass | `CODE_REVIEW.md`의 검증 명령을 재실행해 통과 확인 |
### 발견된 문제
없음
### 다음 단계
PASS: `complete.log` 작성 후 종료한다.

View file

@ -0,0 +1,25 @@
# 완료 로그 - crosstest_coverage_gaps
## 완료 일시
2026-04-25
## 요약
크로스테스트 coverage gap 보강 작업을 3회 리뷰 루프로 완료.
## 루프 이력
| Plan | Code Review | Verdict |
|------|-------------|---------|
| `plan_0.log` | `code_review_0.log` | FAIL |
| `plan_1.log` | `code_review_1.log` | FAIL |
| `plan_2.log` | `code_review_2.log` | PASS |
## 최종 리뷰 요약
- Go/Kotlin/Python/TypeScript self-cell concurrent request-response 테스트 보강을 확인했다.
- Python WS self-cell 테스트 추가와 Dart/Go secure crosstest 보강을 확인했다.
- Kotlin full test hang 원인을 테스트 readiness race로 좁히고, listener/request handler 등록 완료 신호를 await하도록 후속 수정했다.
- Kotlin concurrent 테스트 cleanup이 `try/finally`로 보장되는 것을 확인했다.
- 최종 검증에서 `TcpTest`, `WsTest`, 전체 `./gradlew test`, `git diff --check` 통과를 확인했다.

View file

@ -0,0 +1,263 @@
<!-- task=crosstest_coverage_gaps plan=0 tag=CROSSTEST-COVERAGE -->
# Crosstest Coverage Gaps - 작업 계획
## 이 파일을 읽는 구현 에이전트에게
이 계획은 언어별 크로스테스트 matrix에서 확인된 부족분만 보강한다.
기존 20개 cross-language TCP/WS 조합은 이미 존재하므로, 새 matrix를 다시 만들기보다
같은 언어 self-cell 시나리오 균일화와 secure transport 보강/보류 문서화에 집중한다.
완료 후 `CODE_REVIEW.md`에 실제 변경 파일, 검증 결과, 보류 사유를 채우고 체크박스를 `[x]`로 갱신한다.
## 배경
현재 crosstest 파일 기준으로 Dart, Go, Kotlin, Python, TypeScript의 서로 다른 언어 조합은
서버 언어 row / 클라이언트 언어 cell 기준 20/20이 존재한다.
일반 TCP/WS cross-language 조합은 다음 4개 시나리오를 실행한다.
| Scenario | Requirement |
|----------|-------------|
| 1 | 클라이언트 fire-and-forget `TestData` 송신, 서버 수신 검증 |
| 2 | 서버가 클라이언트로 `TestData(index=200, message=push from ... server)` push |
| 3 | 단일 `sendRequest` 응답이 `index=req.index*2`, `message=echo: req.message`인지 검증 |
| 4 | 여러 동시 `sendRequest`가 nonce/responseNonce로 올바르게 매핑되는지 검증 |
부족분은 두 종류다.
1. 같은 언어 self-cell이 위 4개 시나리오를 TCP/WS 양쪽에서 동일하게 보장한다는 근거가 언어별로 균일하지 않다.
2. secure transport(TLS+TCP, WSS)는 Dart/Go 단위 테스트에는 있으나 cross-language matrix에는 없다. Kotlin/Python/TypeScript는 현재 구현 기준 secure server/client 지원이 부분적이거나 없다.
## 현재 Matrix 요약
범례:
- `CT`: package-local crosstest 존재
- `UT-ok`: 같은 언어 테스트가 TCP/WS scenario 1-4를 충족하는 것으로 확인
- `UT-gap`: 같은 언어 테스트가 일부 scenario를 빠뜨림
| Server \ Client | Dart | Go | Kotlin | Python | TypeScript |
|-----------------|------|----|--------|--------|------------|
| Dart | UT-ok 후보 | CT | CT | CT | CT |
| Go | CT | UT-gap | CT | CT | CT |
| Kotlin | CT | CT | UT-gap | CT | CT |
| Python | CT | CT | CT | UT-gap | CT |
| TypeScript | CT | CT | CT | CT | UT-gap |
구현 전 재확인 사항:
- Dart self-cell은 `dart/test/socket_test.dart`에서 TCP/WS request 동시성까지 확인된다. scenario 1/2까지 명확히 대조한 뒤 부족하면 같은 파일에만 보강한다.
- Go/Kotlin/TypeScript self-cell은 TCP/WS send-push와 단일 request-response는 있으나 동시 request-response scenario가 별도 transport 테스트에 명확하지 않다.
- Python self-cell은 TCP send/receive와 단일 request-response만 있고, Python-only WS 테스트와 동시 request-response 테스트가 부족하다.
## 작업 범위
### [SELF-1] Self-cell coverage audit 표준화
**목표**
각 언어의 같은 언어 셀이 TCP/WS scenario 1-4 중 무엇을 기존 테스트로 만족하는지 코드와 대조한다.
**수정/기록**
- `CODE_REVIEW.md`에 언어별 self-cell 감사 표를 작성한다.
- Dart가 실제로 scenario 1-4를 모두 충족하면 소스 변경 없이 `UT-ok`로 기록한다.
- 부족한 셀만 아래 항목에서 테스트를 추가한다.
### [SELF-2] Go self-cell 동시 request-response 보강
**문제**
`go/test/tcp_test.go`, `go/test/ws_test.go`에는 단일 request-response는 있으나,
cross-language scenario 4와 같은 다중 동시 request-response 검증이 명확하지 않다.
**해결 방법**
- `go/test/tcp_test.go`에 TCP concurrent `SendRequestTyped` 테스트 추가
- `go/test/ws_test.go`에 WS concurrent `SendRequestTyped` 테스트 추가
- 기존 request listener 패턴을 재사용하고, 응답 index/message를 각 요청별로 검증
**수정 파일**
- [x] `go/test/tcp_test.go`
- [x] `go/test/ws_test.go`
### [SELF-3] Kotlin self-cell 동시 request-response 보강
**문제**
`kotlin/src/test/kotlin/com/tokilabs/toki_socket/TcpTest.kt`,
`kotlin/src/test/kotlin/com/tokilabs/toki_socket/WsTest.kt`에는 단일 request-response는 있으나,
transport 레벨의 동시 request-response 검증이 명확하지 않다.
**해결 방법**
- TCP/WS 각각에 `async` 또는 `awaitAll` 기반 concurrent `sendRequestTyped` 테스트 추가
- 기존 `testParserMap()`, `freePort()`, request listener 패턴 재사용
**수정 파일**
- [x] `kotlin/src/test/kotlin/com/tokilabs/toki_socket/TcpTest.kt`
- [x] `kotlin/src/test/kotlin/com/tokilabs/toki_socket/WsTest.kt`
### [SELF-4] Python self-cell TCP/WS scenario 보강
**문제**
Python에는 `python/test/test_tcp.py`와 communicator 단위 테스트가 있으나,
Python-only WS 테스트가 없고 TCP/WS concurrent request-response 검증도 부족하다.
**해결 방법**
- `python/test/test_tcp.py`에 TCP concurrent `send_request` 테스트 추가
- Python WS 테스트 파일을 추가해 WS send-push, 단일 request-response, concurrent request-response를 검증
- 새 파일은 기존 구조상 `python/test/test_ws.py`가 가장 자연스럽다. 새 파일 생성 사유를 `CODE_REVIEW.md`에 기록한다.
**수정 파일**
- [x] `python/test/test_tcp.py`
- [x] `python/test/test_ws.py`
### [SELF-5] TypeScript self-cell 동시 request-response 보강
**문제**
`typescript/test/tcp.test.ts`, `typescript/test/ws.test.ts`에는 send-push와 단일 request-response는 있으나,
transport 레벨의 동시 request-response 검증이 명확하지 않다.
**해결 방법**
- TCP/WS 각각에 `Promise.all` 기반 concurrent `sendRequestTyped` 테스트 추가
- 기존 `parserMap()`, `TcpServer`, `WsServer`, cleanup 패턴 재사용
**수정 파일**
- [x] `typescript/test/tcp.test.ts`
- [x] `typescript/test/ws.test.ts`
### [SECURE-1] Dart <-> Go secure crosstest 추가
**문제**
Dart와 Go는 TLS+TCP 및 WSS API와 단위 테스트가 있으나 cross-language secure transport 검증이 없다.
**해결 방법**
기존 Dart/Go pairwise crosstest를 확장한다.
- Dart server -> Go client:
- `dart/crosstest/dart_go.dart`에 TLS+TCP, WSS phases 추가
- `go/crosstest/dart_go_client/main.go`에 `tls`/`wss` mode 추가
- Go server -> Dart client:
- `go/crosstest/go_dart.go`에 TLS+TCP, WSS phases 추가
- `dart/crosstest/go_dart_client.dart`에 `tls`/`wss` mode 추가
- secure phase는 최소한 `TestData` exchange와 request-response를 검증한다.
- 가능하면 일반 crosstest와 같은 scenario 1-4를 유지한다. 구현 난도가 커지면 scenario 1/3만 먼저 추가하지 말고 계획을 갱신한다.
- 인증서는 test fixture 또는 실행 중 임시 생성 방식을 사용하고, machine-global trust store는 사용하지 않는다.
**수정 파일 후보**
- [x] `dart/crosstest/dart_go.dart`
- [x] `go/crosstest/dart_go_client/main.go`
- [x] `go/crosstest/go_dart.go`
- [x] `dart/crosstest/go_dart_client.dart`
- [x] `dart/test/certs/server.crt` / `dart/test/certs/server.key` 재사용 가능 여부 확인 → SAN에 IP:127.0.0.1, DNS:localhost 포함. 재사용 가능.
### [SECURE-2] Kotlin/Python/TypeScript secure matrix 보류 사유 문서화
**문제**
secure transport matrix를 모든 언어에 적용하려면 언어별 API 지원이 필요하다. 현재 코드 기준:
- Kotlin: WSS client API는 있으나 secure WS server와 TLS+TCP API가 없다.
- Python: TCP/WS client/server에 SSL context 경로가 없다.
- TypeScript: TCP는 `net`, WS는 plain `ws://`만 사용하며 TLS/WSS wrapper가 없다.
**해결 방법**
- `CODE_REVIEW.md`에 secure matrix 보류 표를 작성한다.
- README나 언어별 README가 TLS/WSS 지원을 과하게 주장하는 경우에만 문서를 정정한다.
- secure API 구현 자체는 이 계획의 범위를 넘긴다.
**수정 파일 후보**
- [x] `CODE_REVIEW.md`
- [x] `README.md` 또는 언어별 README는 과한 지원 표기가 있을 때만 수정 → 과잉 주장 없음. 수정 불필요.
## 최종 목표 Matrix
일반 TCP/WS:
| Server \ Client | Dart | Go | Kotlin | Python | TypeScript |
|-----------------|------|----|--------|--------|------------|
| Dart | UT-ok | CT | CT | CT | CT |
| Go | CT | UT-ok | CT | CT | CT |
| Kotlin | CT | CT | UT-ok | CT | CT |
| Python | CT | CT | CT | UT-ok | CT |
| TypeScript | CT | CT | CT | CT | UT-ok |
Secure transport:
| Server \ Client | Dart | Go | Kotlin | Python | TypeScript |
|-----------------|------|----|--------|--------|------------|
| Dart | 기존 단위 테스트 | CT-secure | deferred | deferred | deferred |
| Go | CT-secure | 기존 단위 테스트 | deferred | deferred | deferred |
| Kotlin | deferred | deferred | deferred | deferred | deferred |
| Python | deferred | deferred | deferred | deferred | deferred |
| TypeScript | deferred | deferred | deferred | deferred | deferred |
## 검증 명령
Self-cell 보강 검증:
```bash
cd dart && dart test
cd go && go test ./...
cd kotlin && ./gradlew test
cd python && python3 -m pytest test/ -v
cd typescript && npm test
cd typescript && npm run check
```
Dart/Go secure crosstest 보강 검증:
```bash
cd dart && dart run crosstest/dart_go.dart
cd go && go run ./crosstest/go_dart.go
```
회귀 확인:
```bash
cd go && go run ./crosstest/go_kotlin.go
cd go && go run ./crosstest/go_python.go
cd go && go run ./crosstest/go_typescript.go
cd dart && dart run crosstest/dart_kotlin.dart
cd dart && dart run crosstest/dart_python.dart
cd dart && dart run crosstest/dart_typescript.dart
cd kotlin && ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinGoKt
cd kotlin && ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinPythonKt
cd kotlin && ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinTypescriptKt
cd python && python3 -m crosstest.python_dart
cd python && python3 -m crosstest.python_go
cd python && python3 -m crosstest.python_kotlin
cd python && python3 -m crosstest.python_typescript
cd typescript && node --import tsx crosstest/typescript_dart.ts
cd typescript && node --import tsx crosstest/typescript_go.ts
cd typescript && node --import tsx crosstest/typescript_kotlin.ts
cd typescript && node --import tsx crosstest/typescript_python.ts
```
정리 검증:
```bash
git diff --check
```
## 범위 밖
- Kotlin/Python/TypeScript secure transport API 신규 구현
- 새 언어 추가
- proto/schema 변경
- 기존 plain TCP/WS cross-language matrix 재작성

View file

@ -0,0 +1,101 @@
<!-- task=crosstest_coverage_gaps plan=1 tag=REVIEW_CROSSTEST-COVERAGE -->
# Review Follow-up Plan - REVIEW_CROSSTEST-COVERAGE
## 개요
date=2026-04-25
task=crosstest_coverage_gaps, plan=1, tag=REVIEW_CROSSTEST-COVERAGE
## 배경
plan=0 리뷰 결과 `FAIL` 판정이 나왔다. 구현 자체의 주요 범위는 맞지만, Kotlin self-cell 검증 신뢰도가 확보되지 않았다.
재검증 결과:
- `TcpTest.testTcpConcurrentRequests` 단독 실행 통과
- `WsTest.testWsConcurrentRequests` 단독 실행 통과
- `TcpTest.testTcpSendReceive` 단독 실행 통과
- `./gradlew test` 및 `--tests com.tokilabs.toki_socket.TcpTest`는 `:test` 단계에서 타임아웃 재현
## 작업 범위
### [REVIEW_CROSSTEST-COVERAGE-1] Kotlin 전체 test suite hang 원인 수정
**문제**
`kotlin && ./gradlew test`가 전체 suite에서 완료되지 않는다. 리뷰 중 jstack은 `TcpTest.testTcpSendReceive`의 `received.await()` 대기와 TCP accept/readLoop가 남아 있는 상태를 보였다.
**해결 방향**
- Kotlin TCP/WS 테스트의 서버 시작, 클라이언트 연결, listener 등록 순서를 재검토한다.
- 테스트 간 서버/클라이언트 상태가 남아 다음 테스트에 영향을 주지 않도록 정리 흐름을 통일한다.
- 기존 구현 코어를 불필요하게 바꾸지 말고, 우선 테스트 안정성 문제를 좁혀 수정한다.
**체크리스트**
- [ ] `./gradlew test --tests com.tokilabs.toki_socket.TcpTest`가 타임아웃 없이 통과한다.
- [ ] `./gradlew test`가 타임아웃 없이 통과한다.
- [ ] 기존 TCP/WS self-cell 시나리오 1-4 의미가 약화되지 않는다.
**검증**
```bash
cd kotlin && ./gradlew test --tests com.tokilabs.toki_socket.TcpTest
cd kotlin && ./gradlew test
```
### [REVIEW_CROSSTEST-COVERAGE-2] Kotlin concurrent 테스트 cleanup 보장
**문제**
`testTcpConcurrentRequests`와 `testWsConcurrentRequests`는 `awaitAll()` 뒤에만 `client.close()` / `server.stop()`을 호출한다. 요청 실패나 timeout이 발생하면 cleanup이 건너뛰어 후속 테스트가 hang 될 수 있다.
**해결 방향**
Before:
```kotlin
deferred.awaitAll()
client.close()
server.stop()
```
After:
```kotlin
try {
deferred.awaitAll()
} finally {
client.close()
server.stop()
}
```
필요하면 기존 다른 Kotlin 테스트도 같은 패턴으로 정리하되, 이번 리뷰 이슈와 무관한 리팩터링은 하지 않는다.
**체크리스트**
- [ ] TCP concurrent 테스트가 실패해도 client/server cleanup이 실행된다.
- [ ] WS concurrent 테스트가 실패해도 client/server cleanup이 실행된다.
- [ ] cleanup 보강으로 assertion 의미가 바뀌지 않는다.
**검증**
```bash
cd kotlin && ./gradlew test --tests com.tokilabs.toki_socket.TcpTest.testTcpConcurrentRequests
cd kotlin && ./gradlew test --tests com.tokilabs.toki_socket.WsTest.testWsConcurrentRequests
```
## 최종 검증
```bash
cd kotlin && ./gradlew test
git diff --check
```
## 범위 밖
- Kotlin secure transport API 신규 구현
- Dart/Go/Python/TypeScript 구현 변경
- agent-ops 규칙/스킬 변경

View file

@ -0,0 +1,89 @@
<!-- task=crosstest_coverage_gaps plan=2 tag=REVIEW_REVIEW_CROSSTEST-COVERAGE -->
# Review Follow-up Plan - REVIEW_REVIEW_CROSSTEST-COVERAGE
## 개요
date=2026-04-25
task=crosstest_coverage_gaps, plan=2, tag=REVIEW_REVIEW_CROSSTEST-COVERAGE
## 배경
plan=1 리뷰 결과 `FAIL` 판정이 나왔다. Kotlin `./gradlew test`는 통과했지만, 테스트에서 추가한 `waitForCondition { server.clients().size == 1 }`는 listener/request handler 등록 완료를 보장하지 않는다.
Kotlin 서버 구현은 다음 순서로 동작한다.
```kotlin
clients.add(client)
onClientConnected(client)
```
따라서 테스트가 `server.clients().size == 1` 조건을 만족하더라도 `onClientConnected` 안의 `addListenerTyped` 또는 `addRequestListenerTyped`가 아직 끝나지 않았을 수 있다.
## 작업 범위
### [REVIEW_REVIEW_CROSSTEST-COVERAGE-1] Kotlin 테스트 listener/request handler readiness 보장
**문제**
`testTcpSendReceive`, `testWsSendReceive`, `testTcpConcurrentRequests`, `testWsConcurrentRequests`가 연결 확인만 하고 listener/request handler 등록 완료를 기다리지 않는다. 원래 suite hang의 원인으로 본 race를 완전히 제거하지 못한다.
**해결 방향**
- 각 테스트에서 `CompletableDeferred<Unit>` 같은 readiness 신호를 둔다.
- `server.onClientConnected` 안에서 listener 또는 request listener를 등록한 직후 readiness를 complete한다.
- 클라이언트 연결 후 send/request 시작 전에 readiness를 await한다.
- `server.clients().size` 대기는 연결 수 확인 용도로만 필요할 때 유지하고, listener 등록 완료 근거로 사용하지 않는다.
Before:
```kotlin
server.onClientConnected = { client ->
addListenerTyped<TestData>(client.communicator) { received.complete(it) }
}
val client = DialTcp(...)
waitForCondition(message = "client did not connect") { server.clients().size == 1 }
client.send(...)
```
After:
```kotlin
val listenerReady = CompletableDeferred<Unit>()
server.onClientConnected = { client ->
addListenerTyped<TestData>(client.communicator) { received.complete(it) }
listenerReady.complete(Unit)
}
val client = DialTcp(...)
listenerReady.await()
client.send(...)
```
**체크리스트**
- [ ] TCP send/receive 테스트가 listener 등록 완료 후 send한다.
- [ ] WS send/receive 테스트가 listener 등록 완료 후 send한다.
- [ ] TCP concurrent request 테스트가 request handler 등록 완료 후 요청을 시작한다.
- [ ] WS concurrent request 테스트가 request handler 등록 완료 후 요청을 시작한다.
- [ ] concurrent 테스트의 `try/finally` cleanup은 유지한다.
**검증**
```bash
cd kotlin && ./gradlew test --tests com.tokilabs.toki_socket.TcpTest
cd kotlin && ./gradlew test --tests com.tokilabs.toki_socket.WsTest
```
## 최종 검증
```bash
cd kotlin && ./gradlew test
git diff --check
```
## 범위 밖
- Kotlin production server/client 동작 변경
- Dart/Go/Python/TypeScript 구현 변경
- secure transport API 신규 구현
9

View file

@ -0,0 +1,122 @@
<!-- task=proto_restructure plan=0 tag=PROTO_MOVE -->
# Code Review Reference - PROTO_MOVE
## 개요
date=2026-04-25
task=proto_restructure, plan=0, tag=PROTO_MOVE
## 이 파일을 읽는 리뷰 에이전트에게
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
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` 스텁 작성.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [PROTO_MOVE-1] `proto/` 디렉터리 생성 및 generate_proto.sh 수정 | [x] |
| [PROTO_MOVE-2] `check_proto_sync.sh` canonical 경로 변경 | [x] |
| [PROTO_MOVE-3] `PROTOCOL.md` Proto Source 섹션 업데이트 | [x] |
| [PROTO_MOVE-4] `dart/lib/src/packets/message_common.proto` 제거 | [x] |
## 계획 대비 변경 사항
- `README.md`, `PORTING_GUIDE.md`, `typescript/README.md`에도 이전 canonical 경로(`dart/lib/src/packets/message_common.proto`)가 남아 있어 함께 갱신했다. proto 원본 이동 후 사용자-facing 안내가 서로 충돌하지 않도록 하기 위한 변경이다.
- `agent-ops/rules/project/**`와 새 언어 템플릿에도 이전 proto 원본 경로가 남아 있어, 실제 구조와 다음 작업 지시가 어긋나지 않도록 protocol/dart 규칙 및 템플릿 문구를 함께 갱신했다.
- `tools/generate_proto.sh` 검증을 위해 로컬 PATH에 `protoc-gen-dart`와 임시 Go toolchain/protoc-gen-go를 추가해 실행했다. 스키마가 바뀐 것은 아니므로 생성 산출물(`*.pb.dart`, `*.pb.go`)은 최종 diff에 남기지 않았다.
## 주요 설계 결정
- `proto/message_common.proto`를 언어 옵션 없는 canonical proto로 둔다.
- Go/Kotlin의 proto copy는 기존처럼 언어별 generator option만 보존한다.
- Dart 생성은 `--proto_path="$repo_root/proto"`와 절대 출력 경로를 사용해 repo root의 proto를 입력으로 삼는다.
- `check_proto_sync.sh``canonical_proto`를 기준으로 Go/Kotlin copy를 normalize 비교하고, 오류 메시지에서 "Dart canonical" 표현을 제거한다.
## 리뷰어를 위한 체크포인트
- `proto/message_common.proto` 존재 여부, 내용이 기존 Dart canonical과 일치하는지
- `tools/generate_proto.sh` — Dart 생성이 `proto/` 입력, `dart/lib/src/packets/` 출력으로 변경됐는지
- `tools/check_proto_sync.sh``canonical_proto` 변수가 `proto/message_common.proto` 참조하는지, "Dart canonical" 문구 제거됐는지
- `PROTOCOL.md` — "Proto Source" 섹션이 `proto/message_common.proto`를 canonical로 명시하는지
- `dart/lib/src/packets/message_common.proto` 파일이 삭제됐는지
- `dart/lib/src/packets/``.pb.dart` 생성 결과물은 여전히 존재하는지
- `tools/check_proto_sync.sh` 실행 시 "Proto schemas are in sync." 출력되는지
- Dart/Go/Kotlin 테스트 모두 통과하는지
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### PROTO_MOVE-1 중간 검증
```
$ ls proto/
message_common.proto
$ PATH="$HOME/.pub-cache/bin:/tmp/codex-gopath/bin:/tmp/codex-go/go/bin:$PATH" GOPATH=/tmp/codex-gopath tools/generate_proto.sh
Proto schemas are in sync.
$ cd dart && dart analyze
Analyzing dart...
No issues found!
```
### PROTO_MOVE-2 중간 검증
```
$ tools/check_proto_sync.sh
Proto schemas are in sync.
```
### PROTO_MOVE-3 중간 검증
```
$ grep "canonical" PROTOCOL.md
`proto/message_common.proto` is the canonical packet definition.
$ grep "dart/lib/src/packets/message_common.proto" PROTOCOL.md
(no output)
```
### PROTO_MOVE-4 중간 검증
```
$ ls dart/lib/src/packets/
message_common.pb.dart
message_common.pbenum.dart
message_common.pbjson.dart
message_common.pbserver.dart
$ cd dart && dart analyze
Analyzing dart...
No issues found!
$ cd dart && dart test
00:15 +43: All tests passed!
```
### 최종 검증
```
$ tools/check_proto_sync.sh
Proto schemas are in sync.
$ rg -n "Dart canonical|canonical Dart|dart/lib/src/packets/message_common\.proto" --glob '!agent-task/**'
(no output)
$ cd dart && dart analyze
Analyzing dart...
No issues found!
$ cd dart && dart test
00:15 +43: All tests passed!
$ cd go && PATH="/tmp/codex-go/go/bin:$PATH" GOPATH=/tmp/codex-gopath go build ./...
(no output; exit 0)
$ cd kotlin && env JAVA_HOME=... ./gradlew build
BUILD SUCCESSFUL in 15s
```

View file

@ -0,0 +1,281 @@
<!-- task=proto_restructure plan=0 tag=PROTO_MOVE -->
# Proto 루트 디렉터리 독립화
## 이 파일을 읽는 구현 에이전트에게
각 항목의 체크리스트를 완료하면서 `[ ]``[x]`로 표시하라.
중간 검증 명령은 해당 항목 구현 직후 실행하고, 출력을 `CODE_REVIEW.md`의 검증 결과 섹션에 붙여넣어라.
최종 검증도 마찬가지로 실행 후 출력을 기록하라.
계획과 다르게 구현한 부분은 이유와 함께 `CODE_REVIEW.md`의 "계획 대비 변경 사항"에 기록하라.
⚠️ **이 태스크는 모든 언어의 빌드 경로를 변경한다. 각 검증을 반드시 실행한 후 다음 단계로 넘어가라.**
---
## 배경
현재 proto canonical source가 `dart/lib/src/packets/message_common.proto`에 위치한다.
Dart는 5개 언어 구현체 중 하나이지만 동시에 스펙 원본의 저장소 역할을 하고 있어,
새 기여자에게 "proto = Dart 전용"으로 오해될 수 있다.
`proto/message_common.proto` 루트 디렉터리로 이동하면:
- proto가 특정 언어 종속성 없이 독립 소스임이 명확해진다
- 새 언어 포팅 시 proto 위치가 자명해진다
- `check_proto_sync.sh`의 "canonical Dart proto"라는 명칭이 사라진다
## 언어별 proto 현황
| 언어 | 현재 위치 | 방식 | 변경 여부 |
|------|----------|------|---------|
| Dart | `dart/lib/src/packets/message_common.proto` (canonical) | `protoc --dart_out` (generate_proto.sh) | 제거 후 생성 경로 변경 |
| Go | `go/packets/message_common.proto` (go_package 옵션 포함 복사본) | `protoc --go_out` (generate_proto.sh) | 유지 (언어 옵션 필요), sync 비교 대상 변경 |
| Kotlin | `kotlin/src/main/proto/message_common.proto` (java 옵션 포함 복사본) | Gradle protobuf 플러그인 | 유지 (언어 옵션 필요), sync 비교 대상 변경 |
| TypeScript | `typescript/src/packets/message_common_pb.ts` (생성 결과물만, .proto 없음) | `protoc-gen-es` 수동 실행 | 영향 없음 |
| Python | `python/toki_socket/packets/message_common_pb2.py` (생성 결과물만, .proto 없음) | `protoc --python_out` 수동 실행 | 영향 없음 |
## 의존 관계 및 구현 순서
```
[PROTO_MOVE-1] proto/ 생성 + generate_proto.sh 수정
→ [PROTO_MOVE-2] check_proto_sync.sh canonical 경로 변경
→ [PROTO_MOVE-3] PROTOCOL.md 업데이트
→ [PROTO_MOVE-4] dart/lib/src/packets/message_common.proto 제거
```
---
### [PROTO_MOVE-1] `proto/` 디렉터리 생성 및 generate_proto.sh 수정
**문제**
`tools/generate_proto.sh`가 Dart canonical에서 직접 생성한다.
```bash
# 현재
cd "$repo_root/dart"
protoc --dart_out=lib/src/packets lib/src/packets/message_common.proto
cd "$repo_root/go"
protoc --go_out=. --go_opt=paths=source_relative packets/message_common.proto
```
**해결 방법**
1. `proto/message_common.proto` 생성: `dart/lib/src/packets/message_common.proto`에서 언어 옵션 없이 그대로 복사 (현재 Dart canonical에는 언어 옵션이 없으므로 내용 동일).
2. `generate_proto.sh`의 Dart 생성 경로를 `proto/`에서 `dart/lib/src/packets/`로 출력하도록 변경.
3. Go 생성은 Go copy(`go/packets/message_common.proto`)에서 그대로 진행 (go_package 옵션 보존 필요).
Before (`generate_proto.sh` Dart 섹션):
```bash
(
cd "$repo_root/dart"
protoc --dart_out=lib/src/packets lib/src/packets/message_common.proto
)
```
After:
```bash
(
protoc \
--proto_path="$repo_root/proto" \
--dart_out="$repo_root/dart/lib/src/packets" \
message_common.proto
)
```
Go 섹션은 변경 없음 (Go는 자체 복사본에서 생성).
**수정 파일 및 체크리스트**
- [x] `proto/message_common.proto` 생성 (dart canonical과 동일 내용)
- [x] `tools/generate_proto.sh` — Dart 생성 섹션 경로 변경
**테스트 없음** — generate_proto.sh 실행으로 검증
**중간 검증**
```bash
# protoc가 있는 환경에서 실행
tools/generate_proto.sh
# 오류 없이 완료
# 생성 결과가 기존과 동일한지 확인
cd dart && dart analyze
# No issues found
```
---
### [PROTO_MOVE-2] `check_proto_sync.sh` canonical 경로 변경
**문제**
`tools/check_proto_sync.sh`의 canonical이 `dart_proto` 변수로 Dart 경로를 가리킨다.
```bash
dart_proto="$repo_root/dart/lib/src/packets/message_common.proto"
```
**해결 방법**
변수명과 경로를 `proto/` 기준으로 변경한다.
Before:
```bash
dart_proto="$repo_root/dart/lib/src/packets/message_common.proto"
go_proto="$repo_root/go/packets/message_common.proto"
kotlin_proto="$repo_root/kotlin/src/main/proto/message_common.proto"
if [[ ! -f "$dart_proto" ]]; then
echo "Missing canonical Dart proto: $dart_proto" >&2
exit 1
fi
# ...
normalize_proto "$dart_proto" >"$tmp_dir/dart.proto"
normalize_proto "$go_proto" >"$tmp_dir/go.proto"
# ...
if ! cmp -s "$tmp_dir/dart.proto" "$tmp_dir/go.proto"; then
echo "Proto schema mismatch: go/packets/message_common.proto must match the Dart canonical proto ..."
# ...
```
After:
```bash
canonical_proto="$repo_root/proto/message_common.proto"
go_proto="$repo_root/go/packets/message_common.proto"
kotlin_proto="$repo_root/kotlin/src/main/proto/message_common.proto"
if [[ ! -f "$canonical_proto" ]]; then
echo "Missing canonical proto: $canonical_proto" >&2
exit 1
fi
# ...
normalize_proto "$canonical_proto" >"$tmp_dir/canonical.proto"
normalize_proto "$go_proto" >"$tmp_dir/go.proto"
# ...
if ! cmp -s "$tmp_dir/canonical.proto" "$tmp_dir/go.proto"; then
echo "Proto schema mismatch: go/packets/message_common.proto must match proto/message_common.proto ..."
# ...
```
**수정 파일 및 체크리스트**
- [x] `tools/check_proto_sync.sh``dart_proto``canonical_proto`, 경로를 `proto/message_common.proto`로 변경
- [x] `tools/check_proto_sync.sh` — 오류 메시지에서 "Dart canonical" 문구 제거
- [x] `tools/check_proto_sync.sh` — normalize 비교 대상 변수명 일관성 수정
**중간 검증**
```bash
tools/check_proto_sync.sh
# Proto schemas are in sync.
```
---
### [PROTO_MOVE-3] `PROTOCOL.md` Proto Source 섹션 업데이트
**문제**
`PROTOCOL.md` 하단 "Proto Source" 섹션이 Dart 경로를 canonical로 명시한다.
```markdown
`dart/lib/src/packets/message_common.proto` is the canonical packet definition.
All language implementations must keep the same message schema and generate bindings from it.
```
**해결 방법**
```markdown
`proto/message_common.proto` is the canonical packet definition.
All language implementations generate bindings from it.
Language-specific generation options (go_package, java_package, etc.) are kept in each
language's own proto copy under `go/packets/` and `kotlin/src/main/proto/`.
```
**수정 파일 및 체크리스트**
- [x] `PROTOCOL.md` — "Proto Source" 섹션 경로 및 설명 업데이트
**테스트 없음** — 문서 전용
**중간 검증**
```bash
grep "canonical" PROTOCOL.md
# proto/message_common.proto 언급
grep "dart/lib/src/packets/message_common.proto" PROTOCOL.md
# 출력 없음이 정상
```
---
### [PROTO_MOVE-4] `dart/lib/src/packets/message_common.proto` 제거
**문제**
PROTO_MOVE-1~3 완료 후 `dart/lib/src/packets/message_common.proto`는 더 이상 canonical이 아니다.
그러나 여전히 파일로 남아 혼란을 줄 수 있다.
**해결 방법**
파일을 삭제한다. Dart 생성 결과물인 `.pb.dart` 파일들은 유지한다.
**수정 파일 및 체크리스트**
- [x] `dart/lib/src/packets/message_common.proto` 삭제
- [x] `dart analyze` — proto 파일 제거 후 여전히 No issues인지 확인
**중간 검증**
```bash
ls dart/lib/src/packets/
# message_common.pb.dart, message_common.pbenum.dart 등 생성 결과물만 존재
# message_common.proto 없음
cd dart && dart analyze
# No issues found
cd dart && dart test
# 전체 PASS
```
---
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `proto/message_common.proto` (신규) | PROTO_MOVE-1 |
| `tools/generate_proto.sh` | PROTO_MOVE-1 |
| `tools/check_proto_sync.sh` | PROTO_MOVE-2 |
| `PROTOCOL.md` | PROTO_MOVE-3 |
| `dart/lib/src/packets/message_common.proto` (삭제) | PROTO_MOVE-4 |
## 최종 검증
```bash
# proto sync 도구 검증
tools/check_proto_sync.sh
# Proto schemas are in sync.
# Dart
cd dart && dart analyze
# No issues found
cd dart && dart test
# 전체 PASS
# Go
cd go && go build ./...
# 빌드 성공
# Kotlin
cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle ./gradlew build
# BUILD SUCCESSFUL
# proto/ 파일 확인
ls proto/
# message_common.proto 존재
ls dart/lib/src/packets/
# *.pb.dart만 존재, message_common.proto 없음
```

View file

@ -0,0 +1,122 @@
<!-- task=proto_restructure plan=0 tag=PROTO_MOVE -->
# Code Review Reference - PROTO_MOVE
## 개요
date=2026-04-25
task=proto_restructure, plan=0, tag=PROTO_MOVE
## 이 파일을 읽는 리뷰 에이전트에게
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
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` 스텁 작성.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [PROTO_MOVE-1] `proto/` 디렉터리 생성 및 generate_proto.sh 수정 | [x] |
| [PROTO_MOVE-2] `check_proto_sync.sh` canonical 경로 변경 | [x] |
| [PROTO_MOVE-3] `PROTOCOL.md` Proto Source 섹션 업데이트 | [x] |
| [PROTO_MOVE-4] `dart/lib/src/packets/message_common.proto` 제거 | [x] |
## 계획 대비 변경 사항
- `README.md`, `PORTING_GUIDE.md`, `typescript/README.md`에도 이전 canonical 경로(`dart/lib/src/packets/message_common.proto`)가 남아 있어 함께 갱신했다. proto 원본 이동 후 사용자-facing 안내가 서로 충돌하지 않도록 하기 위한 변경이다.
- `agent-ops/rules/project/**`와 새 언어 템플릿에도 이전 proto 원본 경로가 남아 있어, 실제 구조와 다음 작업 지시가 어긋나지 않도록 protocol/dart 규칙 및 템플릿 문구를 함께 갱신했다.
- `tools/generate_proto.sh` 검증을 위해 로컬 PATH에 `protoc-gen-dart`와 임시 Go toolchain/protoc-gen-go를 추가해 실행했다. 스키마가 바뀐 것은 아니므로 생성 산출물(`*.pb.dart`, `*.pb.go`)은 최종 diff에 남기지 않았다.
## 주요 설계 결정
- `proto/message_common.proto`를 언어 옵션 없는 canonical proto로 둔다.
- Go/Kotlin의 proto copy는 기존처럼 언어별 generator option만 보존한다.
- Dart 생성은 `--proto_path="$repo_root/proto"`와 절대 출력 경로를 사용해 repo root의 proto를 입력으로 삼는다.
- `check_proto_sync.sh`는 `canonical_proto`를 기준으로 Go/Kotlin copy를 normalize 비교하고, 오류 메시지에서 "Dart canonical" 표현을 제거한다.
## 리뷰어를 위한 체크포인트
- `proto/message_common.proto` 존재 여부, 내용이 기존 Dart canonical과 일치하는지
- `tools/generate_proto.sh` — Dart 생성이 `proto/` 입력, `dart/lib/src/packets/` 출력으로 변경됐는지
- `tools/check_proto_sync.sh` — `canonical_proto` 변수가 `proto/message_common.proto` 참조하는지, "Dart canonical" 문구 제거됐는지
- `PROTOCOL.md` — "Proto Source" 섹션이 `proto/message_common.proto`를 canonical로 명시하는지
- `dart/lib/src/packets/message_common.proto` 파일이 삭제됐는지
- `dart/lib/src/packets/` 에 `.pb.dart` 생성 결과물은 여전히 존재하는지
- `tools/check_proto_sync.sh` 실행 시 "Proto schemas are in sync." 출력되는지
- Dart/Go/Kotlin 테스트 모두 통과하는지
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### PROTO_MOVE-1 중간 검증
```
$ ls proto/
message_common.proto
$ PATH="$HOME/.pub-cache/bin:/tmp/codex-gopath/bin:/tmp/codex-go/go/bin:$PATH" GOPATH=/tmp/codex-gopath tools/generate_proto.sh
Proto schemas are in sync.
$ cd dart && dart analyze
Analyzing dart...
No issues found!
```
### PROTO_MOVE-2 중간 검증
```
$ tools/check_proto_sync.sh
Proto schemas are in sync.
```
### PROTO_MOVE-3 중간 검증
```
$ grep "canonical" PROTOCOL.md
`proto/message_common.proto` is the canonical packet definition.
$ grep "dart/lib/src/packets/message_common.proto" PROTOCOL.md
(no output)
```
### PROTO_MOVE-4 중간 검증
```
$ ls dart/lib/src/packets/
message_common.pb.dart
message_common.pbenum.dart
message_common.pbjson.dart
message_common.pbserver.dart
$ cd dart && dart analyze
Analyzing dart...
No issues found!
$ cd dart && dart test
00:15 +43: All tests passed!
```
### 최종 검증
```
$ tools/check_proto_sync.sh
Proto schemas are in sync.
$ rg -n "Dart canonical|canonical Dart|dart/lib/src/packets/message_common\.proto" --glob '!agent-task/**'
(no output)
$ cd dart && dart analyze
Analyzing dart...
No issues found!
$ cd dart && dart test
00:15 +43: All tests passed!
$ cd go && PATH="/tmp/codex-go/go/bin:$PATH" GOPATH=/tmp/codex-gopath go build ./...
(no output; exit 0)
$ cd kotlin && env JAVA_HOME=... ./gradlew build
BUILD SUCCESSFUL in 15s
```

View file

@ -0,0 +1,25 @@
task=proto_restructure
date=2026-04-25
result=PASS
## 완료된 작업 (plan=0)
| 항목 | 내용 |
|------|------|
| PROTO_MOVE-1 | `proto/message_common.proto` 생성 (언어 옵션 없는 canonical). `tools/generate_proto.sh` Dart 섹션을 `--proto_path=proto --dart_out=dart/lib/src/packets/` 로 변경 |
| PROTO_MOVE-2 | `tools/check_proto_sync.sh` canonical 변수를 `canonical_proto="$repo_root/proto/message_common.proto"` 로 변경, "Dart canonical" 문구 제거 |
| PROTO_MOVE-3 | `PROTOCOL.md` "Proto Source" 섹션을 `proto/message_common.proto` 기준으로 갱신 |
| PROTO_MOVE-4 | `dart/lib/src/packets/message_common.proto` 삭제 (`.pb.dart` 생성 결과물만 유지) |
## 계획 외 추가 변경
- `PORTING_GUIDE.md`, `README.md`, `typescript/README.md`의 구 canonical 경로 갱신
- `agent-ops/rules/project/**`, 언어 템플릿의 proto 경로 참조 갱신
## 최종 상태
- `tools/check_proto_sync.sh`: Proto schemas are in sync.
- 구 canonical 경로(`dart/lib/src/packets/message_common.proto`, "Dart canonical") 전역 잔존 없음
- Dart: dart analyze No issues / dart test 43개 PASS
- Go: go build ./... 성공
- Kotlin: ./gradlew build BUILD SUCCESSFUL

View file

@ -0,0 +1,281 @@
<!-- task=proto_restructure plan=0 tag=PROTO_MOVE -->
# Proto 루트 디렉터리 독립화
## 이 파일을 읽는 구현 에이전트에게
각 항목의 체크리스트를 완료하면서 `[ ]`를 `[x]`로 표시하라.
중간 검증 명령은 해당 항목 구현 직후 실행하고, 출력을 `CODE_REVIEW.md`의 검증 결과 섹션에 붙여넣어라.
최종 검증도 마찬가지로 실행 후 출력을 기록하라.
계획과 다르게 구현한 부분은 이유와 함께 `CODE_REVIEW.md`의 "계획 대비 변경 사항"에 기록하라.
⚠️ **이 태스크는 모든 언어의 빌드 경로를 변경한다. 각 검증을 반드시 실행한 후 다음 단계로 넘어가라.**
---
## 배경
현재 proto canonical source가 `dart/lib/src/packets/message_common.proto`에 위치한다.
Dart는 5개 언어 구현체 중 하나이지만 동시에 스펙 원본의 저장소 역할을 하고 있어,
새 기여자에게 "proto = Dart 전용"으로 오해될 수 있다.
`proto/message_common.proto` 루트 디렉터리로 이동하면:
- proto가 특정 언어 종속성 없이 독립 소스임이 명확해진다
- 새 언어 포팅 시 proto 위치가 자명해진다
- `check_proto_sync.sh`의 "canonical Dart proto"라는 명칭이 사라진다
## 언어별 proto 현황
| 언어 | 현재 위치 | 방식 | 변경 여부 |
|------|----------|------|---------|
| Dart | `dart/lib/src/packets/message_common.proto` (canonical) | `protoc --dart_out` (generate_proto.sh) | 제거 후 생성 경로 변경 |
| Go | `go/packets/message_common.proto` (go_package 옵션 포함 복사본) | `protoc --go_out` (generate_proto.sh) | 유지 (언어 옵션 필요), sync 비교 대상 변경 |
| Kotlin | `kotlin/src/main/proto/message_common.proto` (java 옵션 포함 복사본) | Gradle protobuf 플러그인 | 유지 (언어 옵션 필요), sync 비교 대상 변경 |
| TypeScript | `typescript/src/packets/message_common_pb.ts` (생성 결과물만, .proto 없음) | `protoc-gen-es` 수동 실행 | 영향 없음 |
| Python | `python/toki_socket/packets/message_common_pb2.py` (생성 결과물만, .proto 없음) | `protoc --python_out` 수동 실행 | 영향 없음 |
## 의존 관계 및 구현 순서
```
[PROTO_MOVE-1] proto/ 생성 + generate_proto.sh 수정
→ [PROTO_MOVE-2] check_proto_sync.sh canonical 경로 변경
→ [PROTO_MOVE-3] PROTOCOL.md 업데이트
→ [PROTO_MOVE-4] dart/lib/src/packets/message_common.proto 제거
```
---
### [PROTO_MOVE-1] `proto/` 디렉터리 생성 및 generate_proto.sh 수정
**문제**
`tools/generate_proto.sh`가 Dart canonical에서 직접 생성한다.
```bash
# 현재
cd "$repo_root/dart"
protoc --dart_out=lib/src/packets lib/src/packets/message_common.proto
cd "$repo_root/go"
protoc --go_out=. --go_opt=paths=source_relative packets/message_common.proto
```
**해결 방법**
1. `proto/message_common.proto` 생성: `dart/lib/src/packets/message_common.proto`에서 언어 옵션 없이 그대로 복사 (현재 Dart canonical에는 언어 옵션이 없으므로 내용 동일).
2. `generate_proto.sh`의 Dart 생성 경로를 `proto/`에서 `dart/lib/src/packets/`로 출력하도록 변경.
3. Go 생성은 Go copy(`go/packets/message_common.proto`)에서 그대로 진행 (go_package 옵션 보존 필요).
Before (`generate_proto.sh` Dart 섹션):
```bash
(
cd "$repo_root/dart"
protoc --dart_out=lib/src/packets lib/src/packets/message_common.proto
)
```
After:
```bash
(
protoc \
--proto_path="$repo_root/proto" \
--dart_out="$repo_root/dart/lib/src/packets" \
message_common.proto
)
```
Go 섹션은 변경 없음 (Go는 자체 복사본에서 생성).
**수정 파일 및 체크리스트**
- [x] `proto/message_common.proto` 생성 (dart canonical과 동일 내용)
- [x] `tools/generate_proto.sh` — Dart 생성 섹션 경로 변경
**테스트 없음** — generate_proto.sh 실행으로 검증
**중간 검증**
```bash
# protoc가 있는 환경에서 실행
tools/generate_proto.sh
# 오류 없이 완료
# 생성 결과가 기존과 동일한지 확인
cd dart && dart analyze
# No issues found
```
---
### [PROTO_MOVE-2] `check_proto_sync.sh` canonical 경로 변경
**문제**
`tools/check_proto_sync.sh`의 canonical이 `dart_proto` 변수로 Dart 경로를 가리킨다.
```bash
dart_proto="$repo_root/dart/lib/src/packets/message_common.proto"
```
**해결 방법**
변수명과 경로를 `proto/` 기준으로 변경한다.
Before:
```bash
dart_proto="$repo_root/dart/lib/src/packets/message_common.proto"
go_proto="$repo_root/go/packets/message_common.proto"
kotlin_proto="$repo_root/kotlin/src/main/proto/message_common.proto"
if [[ ! -f "$dart_proto" ]]; then
echo "Missing canonical Dart proto: $dart_proto" >&2
exit 1
fi
# ...
normalize_proto "$dart_proto" >"$tmp_dir/dart.proto"
normalize_proto "$go_proto" >"$tmp_dir/go.proto"
# ...
if ! cmp -s "$tmp_dir/dart.proto" "$tmp_dir/go.proto"; then
echo "Proto schema mismatch: go/packets/message_common.proto must match the Dart canonical proto ..."
# ...
```
After:
```bash
canonical_proto="$repo_root/proto/message_common.proto"
go_proto="$repo_root/go/packets/message_common.proto"
kotlin_proto="$repo_root/kotlin/src/main/proto/message_common.proto"
if [[ ! -f "$canonical_proto" ]]; then
echo "Missing canonical proto: $canonical_proto" >&2
exit 1
fi
# ...
normalize_proto "$canonical_proto" >"$tmp_dir/canonical.proto"
normalize_proto "$go_proto" >"$tmp_dir/go.proto"
# ...
if ! cmp -s "$tmp_dir/canonical.proto" "$tmp_dir/go.proto"; then
echo "Proto schema mismatch: go/packets/message_common.proto must match proto/message_common.proto ..."
# ...
```
**수정 파일 및 체크리스트**
- [x] `tools/check_proto_sync.sh` — `dart_proto` → `canonical_proto`, 경로를 `proto/message_common.proto`로 변경
- [x] `tools/check_proto_sync.sh` — 오류 메시지에서 "Dart canonical" 문구 제거
- [x] `tools/check_proto_sync.sh` — normalize 비교 대상 변수명 일관성 수정
**중간 검증**
```bash
tools/check_proto_sync.sh
# Proto schemas are in sync.
```
---
### [PROTO_MOVE-3] `PROTOCOL.md` Proto Source 섹션 업데이트
**문제**
`PROTOCOL.md` 하단 "Proto Source" 섹션이 Dart 경로를 canonical로 명시한다.
```markdown
`dart/lib/src/packets/message_common.proto` is the canonical packet definition.
All language implementations must keep the same message schema and generate bindings from it.
```
**해결 방법**
```markdown
`proto/message_common.proto` is the canonical packet definition.
All language implementations generate bindings from it.
Language-specific generation options (go_package, java_package, etc.) are kept in each
language's own proto copy under `go/packets/` and `kotlin/src/main/proto/`.
```
**수정 파일 및 체크리스트**
- [x] `PROTOCOL.md` — "Proto Source" 섹션 경로 및 설명 업데이트
**테스트 없음** — 문서 전용
**중간 검증**
```bash
grep "canonical" PROTOCOL.md
# proto/message_common.proto 언급
grep "dart/lib/src/packets/message_common.proto" PROTOCOL.md
# 출력 없음이 정상
```
---
### [PROTO_MOVE-4] `dart/lib/src/packets/message_common.proto` 제거
**문제**
PROTO_MOVE-1~3 완료 후 `dart/lib/src/packets/message_common.proto`는 더 이상 canonical이 아니다.
그러나 여전히 파일로 남아 혼란을 줄 수 있다.
**해결 방법**
파일을 삭제한다. Dart 생성 결과물인 `.pb.dart` 파일들은 유지한다.
**수정 파일 및 체크리스트**
- [x] `dart/lib/src/packets/message_common.proto` 삭제
- [x] `dart analyze` — proto 파일 제거 후 여전히 No issues인지 확인
**중간 검증**
```bash
ls dart/lib/src/packets/
# message_common.pb.dart, message_common.pbenum.dart 등 생성 결과물만 존재
# message_common.proto 없음
cd dart && dart analyze
# No issues found
cd dart && dart test
# 전체 PASS
```
---
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `proto/message_common.proto` (신규) | PROTO_MOVE-1 |
| `tools/generate_proto.sh` | PROTO_MOVE-1 |
| `tools/check_proto_sync.sh` | PROTO_MOVE-2 |
| `PROTOCOL.md` | PROTO_MOVE-3 |
| `dart/lib/src/packets/message_common.proto` (삭제) | PROTO_MOVE-4 |
## 최종 검증
```bash
# proto sync 도구 검증
tools/check_proto_sync.sh
# Proto schemas are in sync.
# Dart
cd dart && dart analyze
# No issues found
cd dart && dart test
# 전체 PASS
# Go
cd go && go build ./...
# 빌드 성공
# Kotlin
cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle ./gradlew build
# BUILD SUCCESSFUL
# proto/ 파일 확인
ls proto/
# message_common.proto 존재
ls dart/lib/src/packets/
# *.pb.dart만 존재, message_common.proto 없음
```

View file

@ -7,12 +7,16 @@ import 'package:toki_socket/toki_socket.dart';
const _host = '127.0.0.1';
const _tcpPort = 29090;
const _wsPort = 29092;
const _tlsTcpPort = 29094;
const _wssPort = 29096;
const _heartbeatIntervalSeconds = 30;
const _heartbeatWaitSeconds = 10;
const _serverObservationWindow = Duration(milliseconds: 200);
const _processTimeout = Duration(seconds: 20);
final _repoRoot = File.fromUri(Platform.script).parent.parent.parent.path;
final _goDir = Directory('$_repoRoot/go').path;
final _certFile = '$_repoRoot/dart/test/certs/server.crt';
final _keyFile = '$_repoRoot/dart/test/certs/server.key';
class _DartTcpClient extends ProtobufClient {
_DartTcpClient(Socket socket)
@ -38,6 +42,16 @@ class _DartTcpServer extends ProtobufServer {
void onClientConnected(ProtobufClient client) => onConnected(client);
}
class _DartTlsTcpServer extends ProtobufServer {
final void Function(ProtobufClient) onConnected;
_DartTlsTcpServer(int port, SecurityContext ctx, this.onConnected)
: super.secure(_host, port, ctx, (socket) => _DartTcpClient(socket));
@override
void onClientConnected(ProtobufClient client) => onConnected(client);
}
class _DartWsServer extends WsProtobufServer {
final void Function(WsProtobufClient) onConnected;
@ -48,12 +62,23 @@ class _DartWsServer extends WsProtobufServer {
void onClientConnected(WsProtobufClient client) => onConnected(client);
}
class _DartWssServer extends WsProtobufServer {
final void Function(WsProtobufClient) onConnected;
_DartWssServer(int port, SecurityContext ctx, this.onConnected)
: super.secure(_host, port, ctx, (ws) => _DartWsClient(ws));
@override
void onClientConnected(WsProtobufClient client) => onConnected(client);
}
Future<void> main() async {
print(
'INFO typeName dart=${TestData.getDefault().info_.qualifiedMessageName}');
try {
await _runTcp();
await _runWs();
await _runTls();
print('PASS all dart-server/go-client crosstests passed');
} catch (error) {
stderr.writeln('FAIL crosstest error=$error');
@ -169,17 +194,113 @@ Future<void> _withServer(Future<void> Function() start,
}
}
Future<void> _runTls() async {
final ctx = SecurityContext()
..useCertificateChain(_certFile)
..usePrivateKey(_keyFile);
await _runTlsTcpSendPush(ctx);
await Future<void>.delayed(const Duration(milliseconds: 150));
await _runTlsTcpRequests(ctx);
await Future<void>.delayed(const Duration(milliseconds: 150));
await _runWssSendPush(ctx);
await Future<void>.delayed(const Duration(milliseconds: 150));
await _runWssRequests(ctx);
}
Future<void> _runTlsTcpSendPush(SecurityContext ctx) async {
final received = Completer<bool>();
final server = _DartTlsTcpServer(_tlsTcpPort, ctx, (client) {
client.addListener<TestData>((data) {
final valid = data.index == 101 && data.message == 'fire from go client';
if (!received.isCompleted) received.complete(valid);
if (valid) {
unawaited(client.send(TestData()
..index = 200
..message = 'push from dart server'));
}
});
});
await _withServer(server.start, server.stop, () async {
await _runGoClient('tls', _tlsTcpPort, 'send-push', {'1', '2'},
certFile: _certFile);
final ok = await received.future
.timeout(_serverObservationWindow, onTimeout: () => false);
if (!ok) {
throw StateError('TLS TCP send-push server did not receive expected data');
}
});
}
Future<void> _runTlsTcpRequests(SecurityContext ctx) async {
final server = _DartTlsTcpServer(_tlsTcpPort, ctx, (client) {
client.addRequestListener<TestData, TestData>((req) async {
return TestData()
..index = req.index * 2
..message = 'echo: ${req.message}';
});
});
await _withServer(
server.start,
server.stop,
() => _runGoClient('tls', _tlsTcpPort, 'requests', {'3', '4'},
certFile: _certFile),
);
}
Future<void> _runWssSendPush(SecurityContext ctx) async {
final received = Completer<bool>();
final server = _DartWssServer(_wssPort, ctx, (client) {
client.addListener<TestData>((data) {
final valid = data.index == 101 && data.message == 'fire from go client';
if (!received.isCompleted) received.complete(valid);
if (valid) {
unawaited(client.send(TestData()
..index = 200
..message = 'push from dart server'));
}
});
});
await _withServer(server.start, server.stop, () async {
await _runGoClient('wss', _wssPort, 'send-push', {'1', '2'},
certFile: _certFile);
final ok = await received.future
.timeout(_serverObservationWindow, onTimeout: () => false);
if (!ok) {
throw StateError('WSS send-push server did not receive expected data');
}
});
}
Future<void> _runWssRequests(SecurityContext ctx) async {
final server = _DartWssServer(_wssPort, ctx, (client) {
client.addRequestListener<TestData, TestData>((req) async {
return TestData()
..index = req.index * 2
..message = 'echo: ${req.message}';
});
});
await _withServer(
server.start,
server.stop,
() => _runGoClient('wss', _wssPort, 'requests', {'3', '4'},
certFile: _certFile),
);
}
Future<void> _runGoClient(
String mode, int port, String phase, Set<String> expectedScenarios) async {
String mode, int port, String phase, Set<String> expectedScenarios,
{String? certFile}) async {
final args = [
'run',
'./crosstest/dart_go_client',
'--mode=$mode',
'--port=$port',
'--phase=$phase',
if (certFile != null) '--cert=$certFile',
];
final process = await Process.start(
'go',
[
'run',
'./crosstest/dart_go_client',
'--mode=$mode',
'--port=$port',
'--phase=$phase',
],
args,
workingDirectory: _goDir,
);

View file

@ -35,6 +35,7 @@ Future<void> main(List<String> args) async {
final mode = _argValue(args, 'mode') ?? 'tcp';
final phase = _argValue(args, 'phase') ?? 'send-push';
final port = int.tryParse(_argValue(args, 'port') ?? '');
final certPath = _argValue(args, 'cert');
print(
'INFO typeName dart=${TestData.getDefault().info_.qualifiedMessageName}');
@ -47,7 +48,7 @@ Future<void> main(List<String> args) async {
late _ClientHandle handle;
try {
handle = await _connectWithRetry(mode, port);
handle = await _connectWithRetry(mode, port, certPath: certPath);
} catch (error) {
_fail('setup', error.toString());
exitCode = 1;
@ -76,7 +77,8 @@ Future<void> main(List<String> args) async {
exit(exitCode);
}
Future<_ClientHandle> _connectWithRetry(String mode, int port) async {
Future<_ClientHandle> _connectWithRetry(String mode, int port,
{String? certPath}) async {
final deadline = DateTime.now().add(_connectWindow);
Object? lastError;
@ -93,6 +95,20 @@ Future<_ClientHandle> _connectWithRetry(String mode, int port) async {
.timeout(const Duration(milliseconds: 300));
final client = _WsClient(ws);
return _ClientHandle(client, client.close);
case 'tls':
final ctx = _buildClientSecurityContext(certPath, mode);
final socket =
await ProtobufClient.connectSecure(_host, port, context: ctx)
.timeout(const Duration(milliseconds: 300));
final client = _TcpClient(socket);
return _ClientHandle(client, client.close);
case 'wss':
final ctx = _buildClientSecurityContext(certPath, mode);
final ws = await WsProtobufClient.connectSecure(_host, port,
path: _wsPath, context: ctx)
.timeout(const Duration(milliseconds: 300));
final client = _WsClient(ws);
return _ClientHandle(client, client.close);
default:
throw ArgumentError('unknown mode "$mode"');
}
@ -106,6 +122,13 @@ Future<_ClientHandle> _connectWithRetry(String mode, int port) async {
'connect $mode:$port timed out: $lastError', _connectWindow);
}
SecurityContext _buildClientSecurityContext(String? certPath, String mode) {
if (certPath == null || certPath.isEmpty) {
throw ArgumentError('--cert is required for $mode mode');
}
return SecurityContext()..setTrustedCertificates(certPath);
}
Future<bool> _runSendPush(Communicator client) async {
final pushCompleter = Completer<TestData>();
client.addListener<TestData>((data) {

View file

@ -82,7 +82,8 @@ abstract class Communicator {
/// ```
Future<Res>
sendRequest<Req extends GeneratedMessage, Res extends GeneratedMessage>(
Req data) async {
Req data,
{Duration timeout = const Duration(seconds: 30)}) async {
if (!isAlive) return Future.error(StateError('not connected'));
final requestNonce = ++nonce;
final completer = Completer<Res>();
@ -104,7 +105,11 @@ abstract class Communicator {
_pendingRequests.remove(requestNonce);
completer.completeError(error, stackTrace);
});
return completer.future;
return completer.future.timeout(timeout, onTimeout: () {
_pendingRequests.remove(requestNonce);
throw TimeoutException(
'sendRequest timeout for nonce $requestNonce', timeout);
});
}
/// Registers a request handler for [Req] that returns [Res].

View file

@ -1,3 +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';
@ -47,6 +50,12 @@ class _FakeTransport implements Transport {
Future<void> close() async {}
}
Map<int, Object?> _pendingRequestsOf(Communicator communicator) {
final library = reflectClass(Communicator).owner as LibraryMirror;
final symbol = MirrorSystem.getSymbol('_pendingRequests', library);
return reflect(communicator).getField(symbol).reflectee as Map<int, Object?>;
}
void main() {
group('Communicator protocol guards', () {
test('response typeName mismatch completes sendRequest with error',
@ -96,6 +105,26 @@ void main() {
);
});
test('sendRequest가 timeout 내 응답 없으면 TimeoutException을 던진다', () async {
final communicator = _FakeCommunicator();
final future = communicator.sendRequest<TestData, TestData>(
TestData()
..index = 1
..message = 'wait',
timeout: const Duration(milliseconds: 50),
);
await Future<void>.delayed(Duration.zero);
expect(communicator.transport.sentPackets, hasLength(1));
await expectLater(
future,
throwsA(isA<TimeoutException>()),
);
expect(_pendingRequestsOf(communicator), isEmpty);
});
test(
'cannot register addRequestListener for a type already using addListener',
() {

View file

@ -2,6 +2,8 @@ package main
import (
"context"
"crypto/tls"
"crypto/x509"
"flag"
"fmt"
"os"
@ -37,9 +39,10 @@ func parserMap() toki.ParserMap {
}
func main() {
mode := flag.String("mode", "tcp", "transport mode: tcp or ws")
mode := flag.String("mode", "tcp", "transport mode: tcp, ws, tls, or wss")
port := flag.Int("port", 0, "server port")
phase := flag.String("phase", "send-push", "test phase: send-push or requests")
cert := flag.String("cert", "", "path to PEM certificate for TLS verification")
flag.Parse()
fmt.Printf("INFO typeName go=%s\n", toki.TypeNameOf(&packets.TestData{}))
@ -49,7 +52,7 @@ func main() {
os.Exit(1)
}
client, err := dialWithRetry(*mode, *port)
client, err := dialWithRetry(*mode, *port, *cert)
if err != nil {
fail("setup", err.Error())
os.Exit(1)
@ -71,12 +74,24 @@ func main() {
}
}
func dialWithRetry(mode string, port int) (*clientHandle, error) {
func buildClientTLS(certFile string) (*tls.Config, error) {
certPEM, err := os.ReadFile(certFile)
if err != nil {
return nil, fmt.Errorf("read cert %s: %w", certFile, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(certPEM) {
return nil, fmt.Errorf("no valid PEM certificate found in %s", certFile)
}
return &tls.Config{RootCAs: pool}, nil
}
func dialWithRetry(mode string, port int, certFile string) (*clientHandle, error) {
deadline := time.Now().Add(connectWindow)
var lastErr error
for time.Now().Before(deadline) {
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
handle, err := dial(ctx, mode, port)
handle, err := dial(ctx, mode, port, certFile)
cancel()
if err == nil {
return handle, nil
@ -87,7 +102,7 @@ func dialWithRetry(mode string, port int) (*clientHandle, error) {
return nil, fmt.Errorf("connect %s:%d timed out: %w", mode, port, lastErr)
}
func dial(ctx context.Context, mode string, port int) (*clientHandle, error) {
func dial(ctx context.Context, mode string, port int, certFile string) (*clientHandle, error) {
switch mode {
case "tcp":
client, err := toki.DialTcp(ctx, host, port, 0, 0, parserMap())
@ -109,6 +124,40 @@ func dial(ctx context.Context, mode string, port int) (*clientHandle, error) {
send: client.Send,
close: client.Close,
}, nil
case "tls":
if certFile == "" {
return nil, fmt.Errorf("--cert is required for tls mode")
}
tlsCfg, err := buildClientTLS(certFile)
if err != nil {
return nil, err
}
client, err := toki.DialTcpTLS(ctx, host, port, tlsCfg, 0, 0, parserMap())
if err != nil {
return nil, err
}
return &clientHandle{
communicator: &client.Communicator,
send: client.Send,
close: client.Close,
}, nil
case "wss":
if certFile == "" {
return nil, fmt.Errorf("--cert is required for wss mode")
}
tlsCfg, err := buildClientTLS(certFile)
if err != nil {
return nil, err
}
client, err := toki.DialWssWithHeartbeat(ctx, host, port, wsPath, tlsCfg, 0, 0, parserMap())
if err != nil {
return nil, err
}
return &clientHandle{
communicator: &client.Communicator,
send: client.Send,
close: client.Close,
}, nil
default:
return nil, fmt.Errorf("unknown mode %q", mode)
}

View file

@ -5,6 +5,7 @@ package main
import (
"bufio"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
@ -28,6 +29,8 @@ const (
host = "127.0.0.1"
goDartTCPPort = 29190
goDartWSPort = 29192
goDartTLSPort = 29194
goDartWSSPort = 29196
wsPath = "/"
processTimeout = 20 * time.Second
serverObservationWindow = 200 * time.Millisecond
@ -64,7 +67,43 @@ func run() error {
return err
}
time.Sleep(150 * time.Millisecond)
return runWSRequests()
if err := runWSRequests(); err != nil {
return err
}
time.Sleep(150 * time.Millisecond)
dartDir, err := dartPackageDir()
if err != nil {
return fmt.Errorf("cannot find dart package dir: %w", err)
}
certFile := filepath.Join(dartDir, "test", "certs", "server.crt")
keyFile := filepath.Join(dartDir, "test", "certs", "server.key")
serverTLS, err := loadServerTLS(certFile, keyFile)
if err != nil {
return fmt.Errorf("load TLS certs: %w", err)
}
if err := runTLSTCPSendPush(serverTLS, certFile); err != nil {
return err
}
time.Sleep(150 * time.Millisecond)
if err := runTLSTCPRequests(serverTLS, certFile); err != nil {
return err
}
time.Sleep(150 * time.Millisecond)
if err := runWSSendPushSecure(serverTLS, certFile); err != nil {
return err
}
time.Sleep(150 * time.Millisecond)
return runWSSRequestsSecure(serverTLS, certFile)
}
func loadServerTLS(certFile, keyFile string) (*tls.Config, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, err
}
return &tls.Config{Certificates: []tls.Certificate{cert}}, nil
}
func runTCPSendPush() error {
@ -197,7 +236,133 @@ func runWSRequests() error {
return runDartClient("ws", goDartWSPort, "requests", map[string]bool{"3": true, "4": true})
}
func runDartClient(mode string, port int, phase string, expected map[string]bool) error {
func runTLSTCPSendPush(serverTLS *tls.Config, certFile string) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
received := make(chan bool, 1)
server := toki.NewTcpServerTLS(host, goDartTLSPort, serverTLS, func(conn net.Conn) *toki.TcpClient {
return toki.NewTcpClient(conn, 0, 0, parserMap())
})
server.OnClientConnected = func(client *toki.TcpClient) {
toki.AddListenerTyped[*packets.TestData](&client.Communicator, func(data *packets.TestData) {
fmt.Printf("SERVER_RECEIVED index=%d message=%s\n", data.GetIndex(), data.GetMessage())
valid := data.GetIndex() == 101 && data.GetMessage() == "fire from dart client"
select {
case received <- valid:
default:
}
if valid {
_ = client.Send(&packets.TestData{Index: 200, Message: "push from go server"})
}
})
}
if err := server.Start(ctx); err != nil {
return err
}
defer server.Stop()
if err := runDartClient("tls", goDartTLSPort, "send-push", map[string]bool{"1": true, "2": true}, certFile); err != nil {
return err
}
select {
case ok := <-received:
if !ok {
return errors.New("TLS TCP send-push server received unexpected data")
}
case <-time.After(serverObservationWindow):
return errors.New("TLS TCP send-push server did not receive expected data")
}
return nil
}
func runTLSTCPRequests(serverTLS *tls.Config, certFile string) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
server := toki.NewTcpServerTLS(host, goDartTLSPort, serverTLS, func(conn net.Conn) *toki.TcpClient {
return toki.NewTcpClient(conn, 0, 0, parserMap())
})
server.OnClientConnected = func(client *toki.TcpClient) {
toki.AddRequestListenerTyped[*packets.TestData, *packets.TestData](&client.Communicator, func(req *packets.TestData) (*packets.TestData, error) {
return &packets.TestData{
Index: req.GetIndex() * 2,
Message: "echo: " + req.GetMessage(),
}, nil
})
}
if err := server.Start(ctx); err != nil {
return err
}
defer server.Stop()
return runDartClient("tls", goDartTLSPort, "requests", map[string]bool{"3": true, "4": true}, certFile)
}
func runWSSendPushSecure(serverTLS *tls.Config, certFile string) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
received := make(chan bool, 1)
server := toki.NewWsServerTLS(host, goDartWSSPort, wsPath, serverTLS, func(conn *websocket.Conn) *toki.WsClient {
return toki.NewWsClient(conn, 0, 0, parserMap())
})
server.OnClientConnected = func(client *toki.WsClient) {
toki.AddListenerTyped[*packets.TestData](&client.Communicator, func(data *packets.TestData) {
fmt.Printf("SERVER_RECEIVED index=%d message=%s\n", data.GetIndex(), data.GetMessage())
valid := data.GetIndex() == 101 && data.GetMessage() == "fire from dart client"
select {
case received <- valid:
default:
}
if valid {
_ = client.Send(&packets.TestData{Index: 200, Message: "push from go server"})
}
})
}
if err := server.Start(ctx); err != nil {
return err
}
defer server.Stop()
if err := runDartClient("wss", goDartWSSPort, "send-push", map[string]bool{"1": true, "2": true}, certFile); err != nil {
return err
}
select {
case ok := <-received:
if !ok {
return errors.New("WSS send-push server received unexpected data")
}
case <-time.After(serverObservationWindow):
return errors.New("WSS send-push server did not receive expected data")
}
return nil
}
func runWSSRequestsSecure(serverTLS *tls.Config, certFile string) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
server := toki.NewWsServerTLS(host, goDartWSSPort, wsPath, serverTLS, func(conn *websocket.Conn) *toki.WsClient {
return toki.NewWsClient(conn, 0, 0, parserMap())
})
server.OnClientConnected = func(client *toki.WsClient) {
toki.AddRequestListenerTyped[*packets.TestData, *packets.TestData](&client.Communicator, func(req *packets.TestData) (*packets.TestData, error) {
return &packets.TestData{
Index: req.GetIndex() * 2,
Message: "echo: " + req.GetMessage(),
}, nil
})
}
if err := server.Start(ctx); err != nil {
return err
}
defer server.Stop()
return runDartClient("wss", goDartWSSPort, "requests", map[string]bool{"3": true, "4": true}, certFile)
}
func runDartClient(mode string, port int, phase string, expected map[string]bool, certFile ...string) error {
ctx, cancel := context.WithTimeout(context.Background(), processTimeout)
defer cancel()
@ -206,11 +371,16 @@ func runDartClient(mode string, port int, phase string, expected map[string]bool
return err
}
cmd := exec.CommandContext(ctx, "dart", "run", "crosstest/go_dart_client.dart",
"--mode="+mode,
args := []string{
"run", "crosstest/go_dart_client.dart",
"--mode=" + mode,
fmt.Sprintf("--port=%d", port),
"--phase="+phase,
)
"--phase=" + phase,
}
if len(certFile) > 0 && certFile[0] != "" {
args = append(args, "--cert="+certFile[0])
}
cmd := exec.CommandContext(ctx, "dart", args...)
cmd.Dir = dartDir
stdoutPipe, err := cmd.StdoutPipe()

View file

@ -2,7 +2,9 @@ package toki_socket_test
import (
"context"
"fmt"
"net"
"sync"
"testing"
"time"
@ -145,6 +147,66 @@ func TestTcpClientCloseIdempotent(t *testing.T) {
}
}
func TestTcpConcurrentRequests(t *testing.T) {
port := freePort(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
server := toki.NewTcpServer("127.0.0.1", port, func(conn net.Conn) *toki.TcpClient {
return toki.NewTcpClient(conn, 0, 0, testParserMap())
})
server.OnClientConnected = func(client *toki.TcpClient) {
toki.AddRequestListenerTyped[*packets.TestData, *packets.TestData](&client.Communicator, func(req *packets.TestData) (*packets.TestData, error) {
return &packets.TestData{
Index: req.GetIndex() * 2,
Message: "echo: " + req.GetMessage(),
}, nil
})
}
if err := server.Start(ctx); err != nil {
t.Fatal(err)
}
defer server.Stop()
client, err := toki.DialTcp(ctx, "127.0.0.1", port, 0, 0, testParserMap())
if err != nil {
t.Fatal(err)
}
defer client.Close()
const count = 5
var wg sync.WaitGroup
errCh := make(chan error, count)
for i := 0; i < count; i++ {
i := i
wg.Add(1)
go func() {
defer wg.Done()
index := int32(30 + i)
message := fmt.Sprintf("request %d", i)
res, err := toki.SendRequestTyped[*packets.TestData, *packets.TestData](
&client.Communicator,
&packets.TestData{Index: index, Message: message},
2*time.Second,
)
if err != nil {
errCh <- err
return
}
if res.GetIndex() != index*2 || res.GetMessage() != "echo: "+message {
errCh <- fmt.Errorf("request %d got index=%d message=%q", i, res.GetIndex(), res.GetMessage())
}
}()
}
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
t.Fatal(err)
}
}
}
func TestTcpSendReceive(t *testing.T) {
port := freePort(t)
ctx, cancel := context.WithCancel(context.Background())

View file

@ -2,6 +2,8 @@ package toki_socket_test
import (
"context"
"fmt"
"sync"
"testing"
"time"
@ -51,6 +53,66 @@ func TestWsRequestResponse(t *testing.T) {
}
}
func TestWsConcurrentRequests(t *testing.T) {
port := freePort(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
server := toki.NewWsServer("127.0.0.1", port, "/", func(conn *websocket.Conn) *toki.WsClient {
return toki.NewWsClient(conn, 0, 0, testParserMap())
})
server.OnClientConnected = func(client *toki.WsClient) {
toki.AddRequestListenerTyped[*packets.TestData, *packets.TestData](&client.Communicator, func(req *packets.TestData) (*packets.TestData, error) {
return &packets.TestData{
Index: req.GetIndex() * 2,
Message: "echo: " + req.GetMessage(),
}, nil
})
}
if err := server.Start(ctx); err != nil {
t.Fatal(err)
}
defer server.Stop()
client, err := toki.DialWsWithHeartbeat(ctx, "127.0.0.1", port, "/", 0, 0, testParserMap())
if err != nil {
t.Fatal(err)
}
defer client.Close()
const count = 5
var wg sync.WaitGroup
errCh := make(chan error, count)
for i := 0; i < count; i++ {
i := i
wg.Add(1)
go func() {
defer wg.Done()
index := int32(30 + i)
message := fmt.Sprintf("request %d", i)
res, err := toki.SendRequestTyped[*packets.TestData, *packets.TestData](
&client.Communicator,
&packets.TestData{Index: index, Message: message},
2*time.Second,
)
if err != nil {
errCh <- err
return
}
if res.GetIndex() != index*2 || res.GetMessage() != "echo: "+message {
errCh <- fmt.Errorf("request %d got index=%d message=%q", i, res.GetIndex(), res.GetMessage())
}
}()
}
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
t.Fatal(err)
}
}
}
func TestWsSendReceive(t *testing.T) {
port := freePort(t)
ctx, cancel := context.WithCancel(context.Background())

View file

@ -4,6 +4,7 @@ import com.tokilabs.toki_socket.packets.TestData
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import java.net.Socket
import kotlin.test.Test
@ -15,15 +16,18 @@ class TcpTest {
fun testTcpSendReceive() = runBlocking {
val port = freePort()
val received = CompletableDeferred<TestData>()
val listenerReady = CompletableDeferred<Unit>()
val server = TcpServer("127.0.0.1", port) { socket ->
TcpClient.fromSocket(socket, 0, 0, testParserMap())
}
server.onClientConnected = { client ->
addListenerTyped<TestData>(client.communicator) { received.complete(it) }
listenerReady.complete(Unit)
}
server.start()
val client = DialTcp("127.0.0.1", port, 0, 0, testParserMap())
listenerReady.await()
client.send(TestData.newBuilder().setIndex(42).setMessage("hello toki-socket").build())
assertEquals(42, received.await().index)
@ -97,6 +101,47 @@ class TcpTest {
assertFalse(client.communicator.isAlive())
}
@Test
fun testTcpConcurrentRequests() = runBlocking {
val port = freePort()
val handlerReady = CompletableDeferred<Unit>()
val server = TcpServer("127.0.0.1", port) { socket ->
TcpClient.fromSocket(socket, 0, 0, testParserMap())
}
server.onClientConnected = { client ->
addRequestListenerTyped<TestData, TestData>(client.communicator) { req ->
TestData.newBuilder()
.setIndex(req.index * 2)
.setMessage("echo: ${req.message}")
.build()
}
handlerReady.complete(Unit)
}
server.start()
val client = DialTcp("127.0.0.1", port, 0, 0, testParserMap())
handlerReady.await()
val deferred = (0 until 5).map { i ->
async {
val index = 30 + i
val message = "request $i"
val res = sendRequestTyped<TestData, TestData>(
client.communicator,
TestData.newBuilder().setIndex(index).setMessage(message).build(),
timeoutMs = 2_000,
)
assertEquals(index * 2, res.index)
assertEquals("echo: $message", res.message)
}
}
try {
deferred.awaitAll()
} finally {
client.close()
server.stop()
}
}
@Test
fun testTcpClientCloseIdempotent() = runBlocking {
val serverSocket = java.net.ServerSocket(0)

View file

@ -2,6 +2,8 @@ package com.tokilabs.toki_socket
import com.tokilabs.toki_socket.packets.TestData
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
@ -12,15 +14,18 @@ class WsTest {
fun testWsSendReceive() = runBlocking {
val port = freePort()
val received = CompletableDeferred<TestData>()
val listenerReady = CompletableDeferred<Unit>()
val server = WsServer("127.0.0.1", port, "/") { conn ->
WsClient.forServer(conn, 0, 0, testParserMap())
}
server.onClientConnected = { client ->
addListenerTyped<TestData>(client.communicator) { received.complete(it) }
listenerReady.complete(Unit)
}
server.start()
val client = DialWs("127.0.0.1", port, "/", 0, 0, testParserMap())
listenerReady.await()
client.send(TestData.newBuilder().setIndex(42).setMessage("hello toki-socket ws").build())
assertEquals(42, received.await().index)
@ -81,6 +86,47 @@ class WsTest {
server.stop()
}
@Test
fun testWsConcurrentRequests() = runBlocking {
val port = freePort()
val handlerReady = CompletableDeferred<Unit>()
val server = WsServer("127.0.0.1", port, "/") { conn ->
WsClient.forServer(conn, 0, 0, testParserMap())
}
server.onClientConnected = { client ->
addRequestListenerTyped<TestData, TestData>(client.communicator) { req ->
TestData.newBuilder()
.setIndex(req.index * 2)
.setMessage("echo: ${req.message}")
.build()
}
handlerReady.complete(Unit)
}
server.start()
val client = DialWs("127.0.0.1", port, "/", 0, 0, testParserMap())
handlerReady.await()
val deferred = (0 until 5).map { i ->
async {
val index = 30 + i
val message = "request $i"
val res = sendRequestTyped<TestData, TestData>(
client.communicator,
TestData.newBuilder().setIndex(index).setMessage(message).build(),
timeoutMs = 2_000,
)
assertEquals(index * 2, res.index)
assertEquals("echo: $message", res.message)
}
}
try {
deferred.awaitAll()
} finally {
client.close()
server.stop()
}
}
@Test
fun testWsServerStopDisconnectsClients() = runBlocking {
val port = freePort()

View file

@ -12,4 +12,4 @@ message HeartBeat {}
message TestData {
int32 index = 1;
string message = 2;
}
}

View file

@ -10,6 +10,16 @@ from toki_socket.tcp_client import TcpClient, connect_tcp
from toki_socket.tcp_server import TcpServer
async def _wait_for_clients(server: TcpServer, count: int = 1, timeout: float = 1.0) -> None:
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while loop.time() < deadline:
if len(server.clients()) >= count:
return
await asyncio.sleep(0.01)
raise TimeoutError("server did not see expected client count")
def parser_map():
return {type_name_of(ProtoTestData): ProtoTestData.FromString}
@ -64,3 +74,59 @@ async def test_tcp_request_response():
await client.close()
await server.stop()
@pytest.mark.asyncio
async def test_tcp_server_push():
pushed = asyncio.get_running_loop().create_future()
server = TcpServer(
"127.0.0.1",
0,
lambda reader, writer: TcpClient(reader, writer, 0, 0, parser_map()),
)
await server.start()
client = await connect_tcp("127.0.0.1", server.port, 0, 0, parser_map())
client.communicator.add_listener(
type_name_of(ProtoTestData),
lambda msg: pushed.set_result(msg) if not pushed.done() else None,
)
await _wait_for_clients(server)
await server.broadcast(ProtoTestData(index=200, message="push from python server"))
result = await asyncio.wait_for(pushed, 2)
assert result.index == 200
assert result.message == "push from python server"
await client.close()
await server.stop()
@pytest.mark.asyncio
async def test_tcp_concurrent_requests():
server = TcpServer(
"127.0.0.1",
0,
lambda reader, writer: TcpClient(reader, writer, 0, 0, parser_map()),
)
server.on_client_connected = lambda client: client.communicator.add_request_listener(
type_name_of(ProtoTestData),
lambda req: ProtoTestData(index=req.index * 2, message="echo: " + req.message),
)
await server.start()
client = await connect_tcp("127.0.0.1", server.port, 0, 0, parser_map())
async def do_request(i: int) -> None:
index = 30 + i
message = f"request {i}"
result = await client.communicator.send_request(
ProtoTestData(index=index, message=message),
ProtoTestData,
timeout=2,
)
assert result.index == index * 2
assert result.message == f"echo: {message}"
await asyncio.gather(*[do_request(i) for i in range(5)])
await client.close()
await server.stop()

136
python/test/test_ws.py Normal file
View file

@ -0,0 +1,136 @@
from __future__ import annotations
import asyncio
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_server import WsServer
def parser_map():
return {type_name_of(ProtoTestData): ProtoTestData.FromString}
async def _wait_for_clients(server: WsServer, count: int = 1, timeout: float = 1.0) -> None:
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while loop.time() < deadline:
if len(server.clients()) >= count:
return
await asyncio.sleep(0.01)
raise TimeoutError("server did not see expected client count")
@pytest.mark.asyncio
async def test_ws_send_receive():
received = asyncio.get_running_loop().create_future()
server = WsServer(
"127.0.0.1",
0,
"/",
lambda ws: WsClient(ws, 0, 0, parser_map()),
)
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_ws("127.0.0.1", server.port, "/", 0, 0, parser_map())
await client.communicator.send(ProtoTestData(index=1, message="hello ws"))
result = await asyncio.wait_for(received, 2)
assert result.index == 1
assert result.message == "hello ws"
await client.close()
await server.stop()
@pytest.mark.asyncio
async def test_ws_server_push():
pushed = asyncio.get_running_loop().create_future()
server = WsServer(
"127.0.0.1",
0,
"/",
lambda ws: WsClient(ws, 0, 0, parser_map()),
)
await server.start()
client = await connect_ws("127.0.0.1", server.port, "/", 0, 0, parser_map())
client.communicator.add_listener(
type_name_of(ProtoTestData),
lambda msg: pushed.set_result(msg) if not pushed.done() else None,
)
await _wait_for_clients(server)
await server.broadcast(ProtoTestData(index=200, message="push from python ws server"))
result = await asyncio.wait_for(pushed, 2)
assert result.index == 200
assert result.message == "push from python ws server"
await client.close()
await server.stop()
@pytest.mark.asyncio
async def test_ws_request_response():
server = WsServer(
"127.0.0.1",
0,
"/",
lambda ws: WsClient(ws, 0, 0, parser_map()),
)
server.on_client_connected = lambda client: client.communicator.add_request_listener(
type_name_of(ProtoTestData),
lambda req: ProtoTestData(index=req.index * 2, message="echo: " + req.message),
)
await server.start()
client = await connect_ws("127.0.0.1", server.port, "/", 0, 0, parser_map())
result = await client.communicator.send_request(
ProtoTestData(index=21, message="ws request"),
ProtoTestData,
timeout=2,
)
assert result.index == 42
assert result.message == "echo: ws request"
await client.close()
await server.stop()
@pytest.mark.asyncio
async def test_ws_concurrent_requests():
server = WsServer(
"127.0.0.1",
0,
"/",
lambda ws: WsClient(ws, 0, 0, parser_map()),
)
server.on_client_connected = lambda client: client.communicator.add_request_listener(
type_name_of(ProtoTestData),
lambda req: ProtoTestData(index=req.index * 2, message="echo: " + req.message),
)
await server.start()
client = await connect_ws("127.0.0.1", server.port, "/", 0, 0, parser_map())
async def do_request(i: int) -> None:
index = 30 + i
message = f"request {i}"
result = await client.communicator.send_request(
ProtoTestData(index=index, message=message),
ProtoTestData,
timeout=2,
)
assert result.index == index * 2
assert result.message == f"echo: {message}"
await asyncio.gather(*[do_request(i) for i in range(5)])
await client.close()
await server.stop()

View file

@ -2,12 +2,12 @@
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
dart_proto="$repo_root/dart/lib/src/packets/message_common.proto"
canonical_proto="$repo_root/proto/message_common.proto"
go_proto="$repo_root/go/packets/message_common.proto"
kotlin_proto="$repo_root/kotlin/src/main/proto/message_common.proto"
if [[ ! -f "$dart_proto" ]]; then
echo "Missing canonical Dart proto: $dart_proto" >&2
if [[ ! -f "$canonical_proto" ]]; then
echo "Missing canonical proto: $canonical_proto" >&2
exit 1
fi
@ -35,21 +35,21 @@ normalize_proto() {
' "$1"
}
normalize_proto "$dart_proto" >"$tmp_dir/dart.proto"
normalize_proto "$canonical_proto" >"$tmp_dir/canonical.proto"
normalize_proto "$go_proto" >"$tmp_dir/go.proto"
if [[ -f "$kotlin_proto" ]]; then
normalize_proto "$kotlin_proto" >"$tmp_dir/kotlin.proto"
fi
if ! cmp -s "$tmp_dir/dart.proto" "$tmp_dir/go.proto"; then
echo "Proto schema mismatch: go/packets/message_common.proto must match the Dart canonical proto except option go_package." >&2
diff -u "$tmp_dir/dart.proto" "$tmp_dir/go.proto" >&2
if ! cmp -s "$tmp_dir/canonical.proto" "$tmp_dir/go.proto"; then
echo "Proto schema mismatch: go/packets/message_common.proto must match proto/message_common.proto except option go_package." >&2
diff -u "$tmp_dir/canonical.proto" "$tmp_dir/go.proto" >&2
exit 1
fi
if [[ -f "$tmp_dir/kotlin.proto" ]] && ! cmp -s "$tmp_dir/dart.proto" "$tmp_dir/kotlin.proto"; then
echo "Proto schema mismatch: kotlin/src/main/proto/message_common.proto must match the Dart canonical proto except Java options." >&2
diff -u "$tmp_dir/dart.proto" "$tmp_dir/kotlin.proto" >&2
if [[ -f "$tmp_dir/kotlin.proto" ]] && ! cmp -s "$tmp_dir/canonical.proto" "$tmp_dir/kotlin.proto"; then
echo "Proto schema mismatch: kotlin/src/main/proto/message_common.proto must match proto/message_common.proto except Java options." >&2
diff -u "$tmp_dir/canonical.proto" "$tmp_dir/kotlin.proto" >&2
exit 1
fi

View file

@ -18,8 +18,10 @@ require_binary protoc-gen-dart "Install Dart plugin: dart pub global activate pr
require_binary protoc-gen-go "Install Go plugin: go install google.golang.org/protobuf/cmd/protoc-gen-go@latest, then add GOPATH/bin to PATH."
(
cd "$repo_root/dart"
protoc --dart_out=lib/src/packets lib/src/packets/message_common.proto
protoc \
--proto_path="$repo_root/proto" \
--dart_out="$repo_root/dart/lib/src/packets" \
message_common.proto
)
(

View file

@ -17,10 +17,10 @@ Generated bindings live in `src/packets/`.
```bash
cd typescript
PATH="$PWD/node_modules/.bin:$PATH" protoc \
--proto_path ../dart/lib/src/packets \
--proto_path ../proto \
--es_out src/packets \
--es_opt target=ts \
../dart/lib/src/packets/message_common.proto
message_common.proto
```
After generation, run from the repository root:

View file

@ -52,6 +52,8 @@ export function parserFromSchema<T extends Message>(schema: MessageType<T>): Mes
}
export class Communicator {
private static readonly MAX_WRITE_QUEUE_SIZE = 64;
private nonce = 0;
private isAliveFlag = false;
private parserMap: ParserMap = new Map();
@ -60,6 +62,7 @@ export class Communicator {
private reqHandlers = new Map<string, RequestHandler>();
private pendingRequests = new Map<number, PendingRequest>();
private writeQueue: QueueItem[] = [];
private writeQueueWaiters: Array<() => void> = [];
private writeQueueRunning = false;
private transport: Transport | null = null;
private writeErrorHandler: ((err: Error) => void) | null = null;
@ -74,6 +77,7 @@ export class Communicator {
this.reqHandlers = new Map();
this.pendingRequests = new Map();
this.writeQueue = [];
this.writeQueueWaiters = [];
this.writeQueueRunning = false;
this.writeErrorHandler = null;
this.nonce = 0;
@ -127,6 +131,10 @@ export class Communicator {
this.writeQueue.shift()?.reject(new NotConnectedError());
}
while (this.writeQueueWaiters.length > 0) {
this.writeQueueWaiters.shift()?.();
}
this.finishClosed();
}
@ -142,6 +150,15 @@ export class Communicator {
throw new NotConnectedError();
}
while (this.writeQueue.length >= Communicator.MAX_WRITE_QUEUE_SIZE) {
await new Promise<void>((resolve) => {
this.writeQueueWaiters.push(resolve);
});
if (!this.isAliveFlag || this.transport === null) {
throw new NotConnectedError();
}
}
const promise = new Promise<void>((resolve, reject) => {
this.writeQueue.push({ base, resolve, reject });
});
@ -355,6 +372,9 @@ export class Communicator {
if (item === undefined) {
continue;
}
if (this.writeQueueWaiters.length > 0) {
this.writeQueueWaiters.shift()?.();
}
if (!this.isAliveFlag || this.transport === null) {
item.reject(new NotConnectedError());
continue;

View file

@ -23,10 +23,52 @@ class MockTransport implements Transport {
async close(): Promise<void> {}
}
class BlockingTransport implements Transport {
packets: PacketBase[] = [];
private writeResolvers: Array<() => void> = [];
async writePacket(base: PacketBase): Promise<void> {
this.packets.push(base);
await new Promise<void>((resolve) => {
this.writeResolvers.push(resolve);
});
}
releaseNext(): void {
this.writeResolvers.shift()?.();
}
releaseAll(): void {
while (this.writeResolvers.length > 0) {
this.releaseNext();
}
}
async close(): Promise<void> {}
}
function parserMap() {
return new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]);
}
function packet(nonce: number): PacketBase {
return create(PacketBaseSchema, {
typeName: TestDataSchema.typeName,
nonce,
data: new Uint8Array(),
});
}
async function waitFor(predicate: () => boolean): Promise<void> {
for (let i = 0; i < 20; i += 1) {
if (predicate()) {
return;
}
await Promise.resolve();
}
throw new Error("condition not met");
}
describe("Communicator", () => {
test("addListener/addRequestListener mutual exclusion", () => {
const comm = new Communicator();
@ -131,6 +173,53 @@ describe("Communicator", () => {
]);
});
test("write queue가 MAX_WRITE_QUEUE_SIZE 초과 시 대기 후 순서대로 처리된다", async () => {
const transport = new BlockingTransport();
const comm = new Communicator();
comm.initialize(transport, parserMap());
const state = comm as unknown as {
writeQueue: unknown[];
writeQueueWaiters: unknown[];
};
const promises: Array<Promise<unknown>> = [];
for (let nonce = 1; nonce <= 65; nonce += 1) {
promises.push(
comm.queuePacket(packet(nonce)).catch((err: unknown) => err),
);
}
expect(transport.packets.map((sentPacket) => sentPacket.nonce)).toEqual([
1,
]);
expect(state.writeQueue).toHaveLength(64);
expect(state.writeQueueWaiters).toHaveLength(0);
promises.push(comm.queuePacket(packet(66)).catch((err: unknown) => err));
await waitFor(() => state.writeQueueWaiters.length === 1);
expect(transport.packets.map((sentPacket) => sentPacket.nonce)).toEqual([
1,
]);
expect(state.writeQueue).toHaveLength(64);
transport.releaseNext();
await waitFor(
() =>
transport.packets.length === 2 &&
state.writeQueue.length === 64 &&
state.writeQueueWaiters.length === 0,
);
expect(transport.packets.map((sentPacket) => sentPacket.nonce)).toEqual([
1, 2,
]);
comm.shutdown();
transport.releaseAll();
await Promise.allSettled(promises);
});
test("sendRequest stores protobuf packet payload", async () => {
const transport = new MockTransport();
const comm = new Communicator();

View file

@ -89,4 +89,38 @@ describe("TCP", () => {
message: "echo: request over tcp",
});
});
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());
await server.start();
server.onClientConnected = (client) => {
addRequestListenerTyped(client.communicator, TestDataSchema, (req) =>
create(TestDataSchema, {
index: req.index * 2,
message: `echo: ${req.message}`,
}),
);
};
const client = await connectTcp("127.0.0.1", server.port, 0, 0, parserMap());
cleanup.push(async () => client.close());
const results = await Promise.all(
Array.from({ length: 5 }, (_, i) =>
sendRequestTyped(
client.communicator,
create(TestDataSchema, { index: 30 + i, message: `request ${i}` }),
TestDataSchema,
2000,
),
),
);
results.forEach((res, i) => {
expect(res.index).toBe((30 + i) * 2);
expect(res.message).toBe(`echo: request ${i}`);
});
});
});

View file

@ -89,4 +89,38 @@ describe("WS", () => {
message: "echo: request over ws",
});
});
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());
await server.start();
server.onClientConnected = (client) => {
addRequestListenerTyped(client.communicator, TestDataSchema, (req) =>
create(TestDataSchema, {
index: req.index * 2,
message: `echo: ${req.message}`,
}),
);
};
const client = await connectWs("127.0.0.1", server.port, "/", 0, 0, parserMap());
cleanup.push(async () => client.close());
const results = await Promise.all(
Array.from({ length: 5 }, (_, i) =>
sendRequestTyped(
client.communicator,
create(TestDataSchema, { index: 30 + i, message: `request ${i}` }),
TestDataSchema,
2000,
),
),
);
results.forEach((res, i) => {
expect(res.index).toBe((30 + i) * 2);
expect(res.message).toBe(`echo: request ${i}`);
});
});
});