diff --git a/PORTING_GUIDE.md b/PORTING_GUIDE.md index 775be6e..db0f831 100644 --- a/PORTING_GUIDE.md +++ b/PORTING_GUIDE.md @@ -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를 모두 통과시킨다. diff --git a/PROTOCOL.md b/PROTOCOL.md index 10cca75..a532107 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -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. diff --git a/README.md b/README.md index fc2ec8d..614340b 100644 --- a/README.md +++ b/README.md @@ -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`. --- diff --git a/agent-ops/rules/project/domain/dart/rules.md b/agent-ops/rules/project/domain/dart/rules.md index d4bd6fc..cf03015 100644 --- a/agent-ops/rules/project/domain/dart/rules.md +++ b/agent-ops/rules/project/domain/dart/rules.md @@ -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 도메인 규칙을 먼저 따른다 diff --git a/agent-ops/rules/project/domain/protocol/rules.md b/agent-ops/rules/project/domain/protocol/rules.md index 6690f2a..66991e0 100644 --- a/agent-ops/rules/project/domain/protocol/rules.md +++ b/agent-ops/rules/project/domain/protocol/rules.md @@ -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에 정의되지 않은 프레이밍 방식을 구현체에서 임의로 추가하지 않는다 diff --git a/agent-ops/rules/project/rules.md b/agent-ops/rules/project/rules.md index b0345f1..370b880 100644 --- a/agent-ops/rules/project/rules.md +++ b/agent-ops/rules/project/rules.md @@ -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//` — 에이전트 작업 로그 (plan, code_review 등). 소스 코드 아님. 수정하지 않는다. +- `agent-task//` — 에이전트 작업 로그 (plan, code_review 등). 소스 코드 아님. PLAN.md 또는 CODE_REVIEW.md 파일을 읽고 작업하는 작업이 아닌이상. 수정하지 않는다. ## 스킬 라우팅 diff --git a/agent-ops/skills/project/add-toki-socket-crosstest-language/templates/README.md b/agent-ops/skills/project/add-toki-socket-crosstest-language/templates/README.md index f66310a..c5e140a 100644 --- a/agent-ops/skills/project/add-toki-socket-crosstest-language/templates/README.md +++ b/agent-ops/skills/project/add-toki-socket-crosstest-language/templates/README.md @@ -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 # diff --git a/agent-task/api_consistency/CODE_REVIEW.md b/agent-task/api_consistency/CODE_REVIEW.md new file mode 100644 index 0000000..d6ee3ad --- /dev/null +++ b/agent-task/api_consistency/CODE_REVIEW.md @@ -0,0 +1,101 @@ + + +# 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) +``` diff --git a/agent-task/api_consistency/PLAN.md b/agent-task/api_consistency/PLAN.md new file mode 100644 index 0000000..c007d5d --- /dev/null +++ b/agent-task/api_consistency/PLAN.md @@ -0,0 +1,225 @@ + + +# 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 sendRequest( + 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 sendRequest( + Req data) async { + if (!isAlive) return Future.error(StateError('not connected')); + final requestNonce = ++nonce; + // ... + return completer.future; +} +``` + +After: +```dart +Future sendRequest( + 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())` 단언, 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 { + if (!this.isAliveFlag || this.transport === null) { + throw new NotConnectedError(); + } + // 큐가 가득 찬 경우 빈 자리가 생길 때까지 대기 + while (this.writeQueue.length >= Communicator.MAX_WRITE_QUEUE_SIZE) { + await new Promise((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 (신규 큐 테스트 포함) +``` diff --git a/agent-task/api_consistency/code_review_0.log b/agent-task/api_consistency/code_review_0.log new file mode 100644 index 0000000..d6ee3ad --- /dev/null +++ b/agent-task/api_consistency/code_review_0.log @@ -0,0 +1,101 @@ + + +# 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) +``` diff --git a/agent-task/api_consistency/complete.log b/agent-task/api_consistency/complete.log new file mode 100644 index 0000000..ae8c29b --- /dev/null +++ b/agent-task/api_consistency/complete.log @@ -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 큐 포화 테스트) diff --git a/agent-task/api_consistency/plan_0.log b/agent-task/api_consistency/plan_0.log new file mode 100644 index 0000000..c007d5d --- /dev/null +++ b/agent-task/api_consistency/plan_0.log @@ -0,0 +1,225 @@ + + +# 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 sendRequest( + 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 sendRequest( + Req data) async { + if (!isAlive) return Future.error(StateError('not connected')); + final requestNonce = ++nonce; + // ... + return completer.future; +} +``` + +After: +```dart +Future sendRequest( + 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())` 단언, 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 { + if (!this.isAliveFlag || this.transport === null) { + throw new NotConnectedError(); + } + // 큐가 가득 찬 경우 빈 자리가 생길 때까지 대기 + while (this.writeQueue.length >= Communicator.MAX_WRITE_QUEUE_SIZE) { + await new Promise((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 (신규 큐 테스트 포함) +``` diff --git a/agent-task/crosstest_coverage_gaps/code_review_0.log b/agent-task/crosstest_coverage_gaps/code_review_0.log new file mode 100644 index 0000000..81269ba --- /dev/null +++ b/agent-task/crosstest_coverage_gaps/code_review_0.log @@ -0,0 +1,187 @@ + + +# 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=` 인수로 전달. 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` 스텁을 작성해 리뷰 루프를 계속한다. diff --git a/agent-task/crosstest_coverage_gaps/code_review_1.log b/agent-task/crosstest_coverage_gaps/code_review_1.log new file mode 100644 index 0000000..b53b649 --- /dev/null +++ b/agent-task/crosstest_coverage_gaps/code_review_1.log @@ -0,0 +1,99 @@ + + +# 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` 같은 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` 스텁을 작성해 리뷰 루프를 계속한다. diff --git a/agent-task/crosstest_coverage_gaps/code_review_2.log b/agent-task/crosstest_coverage_gaps/code_review_2.log new file mode 100644 index 0000000..a92a562 --- /dev/null +++ b/agent-task/crosstest_coverage_gaps/code_review_2.log @@ -0,0 +1,95 @@ + + +# 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()`을 선언하고, `onClientConnected` 안에서 `addListenerTyped` 직후 `listenerReady.complete(Unit)` 호출. 기존 `waitForCondition { server.clients().size == 1 }`을 `listenerReady.await()`으로 교체. +- `testTcpConcurrentRequests` / `testWsConcurrentRequests`: `val handlerReady = CompletableDeferred()`을 선언하고, `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` 작성 후 종료한다. diff --git a/agent-task/crosstest_coverage_gaps/complete.log b/agent-task/crosstest_coverage_gaps/complete.log new file mode 100644 index 0000000..eedcc23 --- /dev/null +++ b/agent-task/crosstest_coverage_gaps/complete.log @@ -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` 통과를 확인했다. diff --git a/agent-task/crosstest_coverage_gaps/plan_0.log b/agent-task/crosstest_coverage_gaps/plan_0.log new file mode 100644 index 0000000..8813444 --- /dev/null +++ b/agent-task/crosstest_coverage_gaps/plan_0.log @@ -0,0 +1,263 @@ + + +# 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 재작성 diff --git a/agent-task/crosstest_coverage_gaps/plan_1.log b/agent-task/crosstest_coverage_gaps/plan_1.log new file mode 100644 index 0000000..37c3d8b --- /dev/null +++ b/agent-task/crosstest_coverage_gaps/plan_1.log @@ -0,0 +1,101 @@ + + +# 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 규칙/스킬 변경 diff --git a/agent-task/crosstest_coverage_gaps/plan_2.log b/agent-task/crosstest_coverage_gaps/plan_2.log new file mode 100644 index 0000000..6d7ba87 --- /dev/null +++ b/agent-task/crosstest_coverage_gaps/plan_2.log @@ -0,0 +1,89 @@ + + +# 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` 같은 readiness 신호를 둔다. +- `server.onClientConnected` 안에서 listener 또는 request listener를 등록한 직후 readiness를 complete한다. +- 클라이언트 연결 후 send/request 시작 전에 readiness를 await한다. +- `server.clients().size` 대기는 연결 수 확인 용도로만 필요할 때 유지하고, listener 등록 완료 근거로 사용하지 않는다. + +Before: + +```kotlin +server.onClientConnected = { client -> + addListenerTyped(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() +server.onClientConnected = { client -> + addListenerTyped(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 \ No newline at end of file diff --git a/agent-task/proto_restructure/CODE_REVIEW.md b/agent-task/proto_restructure/CODE_REVIEW.md new file mode 100644 index 0000000..492ed94 --- /dev/null +++ b/agent-task/proto_restructure/CODE_REVIEW.md @@ -0,0 +1,122 @@ + + +# 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 +``` diff --git a/agent-task/proto_restructure/PLAN.md b/agent-task/proto_restructure/PLAN.md new file mode 100644 index 0000000..5d9723d --- /dev/null +++ b/agent-task/proto_restructure/PLAN.md @@ -0,0 +1,281 @@ + + +# 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 없음 +``` diff --git a/agent-task/proto_restructure/code_review_0.log b/agent-task/proto_restructure/code_review_0.log new file mode 100644 index 0000000..492ed94 --- /dev/null +++ b/agent-task/proto_restructure/code_review_0.log @@ -0,0 +1,122 @@ + + +# 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 +``` diff --git a/agent-task/proto_restructure/complete.log b/agent-task/proto_restructure/complete.log new file mode 100644 index 0000000..cead48f --- /dev/null +++ b/agent-task/proto_restructure/complete.log @@ -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 diff --git a/agent-task/proto_restructure/plan_0.log b/agent-task/proto_restructure/plan_0.log new file mode 100644 index 0000000..5d9723d --- /dev/null +++ b/agent-task/proto_restructure/plan_0.log @@ -0,0 +1,281 @@ + + +# 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 없음 +``` diff --git a/dart/crosstest/dart_go.dart b/dart/crosstest/dart_go.dart index 4c7cf3b..1ed4580 100644 --- a/dart/crosstest/dart_go.dart +++ b/dart/crosstest/dart_go.dart @@ -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 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 _withServer(Future Function() start, } } +Future _runTls() async { + final ctx = SecurityContext() + ..useCertificateChain(_certFile) + ..usePrivateKey(_keyFile); + await _runTlsTcpSendPush(ctx); + await Future.delayed(const Duration(milliseconds: 150)); + await _runTlsTcpRequests(ctx); + await Future.delayed(const Duration(milliseconds: 150)); + await _runWssSendPush(ctx); + await Future.delayed(const Duration(milliseconds: 150)); + await _runWssRequests(ctx); +} + +Future _runTlsTcpSendPush(SecurityContext ctx) async { + final received = Completer(); + final server = _DartTlsTcpServer(_tlsTcpPort, ctx, (client) { + client.addListener((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 _runTlsTcpRequests(SecurityContext ctx) async { + final server = _DartTlsTcpServer(_tlsTcpPort, ctx, (client) { + client.addRequestListener((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 _runWssSendPush(SecurityContext ctx) async { + final received = Completer(); + final server = _DartWssServer(_wssPort, ctx, (client) { + client.addListener((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 _runWssRequests(SecurityContext ctx) async { + final server = _DartWssServer(_wssPort, ctx, (client) { + client.addRequestListener((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 _runGoClient( - String mode, int port, String phase, Set expectedScenarios) async { + String mode, int port, String phase, Set 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, ); diff --git a/dart/crosstest/go_dart_client.dart b/dart/crosstest/go_dart_client.dart index a79a26c..7c22c5b 100644 --- a/dart/crosstest/go_dart_client.dart +++ b/dart/crosstest/go_dart_client.dart @@ -35,6 +35,7 @@ Future main(List 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 main(List 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 main(List 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 _runSendPush(Communicator client) async { final pushCompleter = Completer(); client.addListener((data) { diff --git a/dart/lib/src/communicator.dart b/dart/lib/src/communicator.dart index 0309f29..1e73feb 100644 --- a/dart/lib/src/communicator.dart +++ b/dart/lib/src/communicator.dart @@ -82,7 +82,8 @@ abstract class Communicator { /// ``` Future sendRequest( - 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(); @@ -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]. diff --git a/dart/test/communicator_test.dart b/dart/test/communicator_test.dart index b9f3eab..2e201f4 100644 --- a/dart/test/communicator_test.dart +++ b/dart/test/communicator_test.dart @@ -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 close() async {} } +Map _pendingRequestsOf(Communicator communicator) { + final library = reflectClass(Communicator).owner as LibraryMirror; + final symbol = MirrorSystem.getSymbol('_pendingRequests', library); + return reflect(communicator).getField(symbol).reflectee as Map; +} + 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() + ..index = 1 + ..message = 'wait', + timeout: const Duration(milliseconds: 50), + ); + + await Future.delayed(Duration.zero); + expect(communicator.transport.sentPackets, hasLength(1)); + + await expectLater( + future, + throwsA(isA()), + ); + expect(_pendingRequestsOf(communicator), isEmpty); + }); + test( 'cannot register addRequestListener for a type already using addListener', () { diff --git a/go/crosstest/dart_go_client/main.go b/go/crosstest/dart_go_client/main.go index 9f56b36..8aab451 100644 --- a/go/crosstest/dart_go_client/main.go +++ b/go/crosstest/dart_go_client/main.go @@ -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) } diff --git a/go/crosstest/go_dart.go b/go/crosstest/go_dart.go index a9c56c2..eef06b2 100644 --- a/go/crosstest/go_dart.go +++ b/go/crosstest/go_dart.go @@ -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() diff --git a/go/test/tcp_test.go b/go/test/tcp_test.go index 98d1062..584e20b 100644 --- a/go/test/tcp_test.go +++ b/go/test/tcp_test.go @@ -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()) diff --git a/go/test/ws_test.go b/go/test/ws_test.go index 06a6ebe..ff4142c 100644 --- a/go/test/ws_test.go +++ b/go/test/ws_test.go @@ -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()) diff --git a/kotlin/src/test/kotlin/com/tokilabs/toki_socket/TcpTest.kt b/kotlin/src/test/kotlin/com/tokilabs/toki_socket/TcpTest.kt index 5f7cabc..5db0b49 100644 --- a/kotlin/src/test/kotlin/com/tokilabs/toki_socket/TcpTest.kt +++ b/kotlin/src/test/kotlin/com/tokilabs/toki_socket/TcpTest.kt @@ -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() + val listenerReady = CompletableDeferred() val server = TcpServer("127.0.0.1", port) { socket -> TcpClient.fromSocket(socket, 0, 0, testParserMap()) } server.onClientConnected = { client -> addListenerTyped(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() + val server = TcpServer("127.0.0.1", port) { socket -> + TcpClient.fromSocket(socket, 0, 0, testParserMap()) + } + server.onClientConnected = { client -> + addRequestListenerTyped(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( + 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) diff --git a/kotlin/src/test/kotlin/com/tokilabs/toki_socket/WsTest.kt b/kotlin/src/test/kotlin/com/tokilabs/toki_socket/WsTest.kt index 4502d9b..f7591ba 100644 --- a/kotlin/src/test/kotlin/com/tokilabs/toki_socket/WsTest.kt +++ b/kotlin/src/test/kotlin/com/tokilabs/toki_socket/WsTest.kt @@ -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() + val listenerReady = CompletableDeferred() val server = WsServer("127.0.0.1", port, "/") { conn -> WsClient.forServer(conn, 0, 0, testParserMap()) } server.onClientConnected = { client -> addListenerTyped(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() + val server = WsServer("127.0.0.1", port, "/") { conn -> + WsClient.forServer(conn, 0, 0, testParserMap()) + } + server.onClientConnected = { client -> + addRequestListenerTyped(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( + 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() diff --git a/dart/lib/src/packets/message_common.proto b/proto/message_common.proto similarity index 99% rename from dart/lib/src/packets/message_common.proto rename to proto/message_common.proto index 02ffe92..cf5e741 100644 --- a/dart/lib/src/packets/message_common.proto +++ b/proto/message_common.proto @@ -12,4 +12,4 @@ message HeartBeat {} message TestData { int32 index = 1; string message = 2; -} \ No newline at end of file +} diff --git a/python/test/test_tcp.py b/python/test/test_tcp.py index ef73bea..506940b 100644 --- a/python/test/test_tcp.py +++ b/python/test/test_tcp.py @@ -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() diff --git a/python/test/test_ws.py b/python/test/test_ws.py new file mode 100644 index 0000000..44111f8 --- /dev/null +++ b/python/test/test_ws.py @@ -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() diff --git a/tools/check_proto_sync.sh b/tools/check_proto_sync.sh index f3707d7..929ba32 100755 --- a/tools/check_proto_sync.sh +++ b/tools/check_proto_sync.sh @@ -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 diff --git a/tools/generate_proto.sh b/tools/generate_proto.sh index 3073d42..9f4e031 100755 --- a/tools/generate_proto.sh +++ b/tools/generate_proto.sh @@ -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 ) ( diff --git a/typescript/README.md b/typescript/README.md index 398326e..925acb0 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -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: diff --git a/typescript/src/communicator.ts b/typescript/src/communicator.ts index 26c0c03..fd0c6c0 100644 --- a/typescript/src/communicator.ts +++ b/typescript/src/communicator.ts @@ -52,6 +52,8 @@ export function parserFromSchema(schema: MessageType): 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(); private pendingRequests = new Map(); 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((resolve) => { + this.writeQueueWaiters.push(resolve); + }); + if (!this.isAliveFlag || this.transport === null) { + throw new NotConnectedError(); + } + } + const promise = new Promise((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; diff --git a/typescript/test/communicator.test.ts b/typescript/test/communicator.test.ts index 319b272..36ca2ff 100644 --- a/typescript/test/communicator.test.ts +++ b/typescript/test/communicator.test.ts @@ -23,10 +23,52 @@ class MockTransport implements Transport { async close(): Promise {} } +class BlockingTransport implements Transport { + packets: PacketBase[] = []; + private writeResolvers: Array<() => void> = []; + + async writePacket(base: PacketBase): Promise { + this.packets.push(base); + await new Promise((resolve) => { + this.writeResolvers.push(resolve); + }); + } + + releaseNext(): void { + this.writeResolvers.shift()?.(); + } + + releaseAll(): void { + while (this.writeResolvers.length > 0) { + this.releaseNext(); + } + } + + async close(): Promise {} +} + 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 { + 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> = []; + + 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(); diff --git a/typescript/test/tcp.test.ts b/typescript/test/tcp.test.ts index fa926a2..b9fe5ea 100644 --- a/typescript/test/tcp.test.ts +++ b/typescript/test/tcp.test.ts @@ -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}`); + }); + }); }); diff --git a/typescript/test/ws.test.ts b/typescript/test/ws.test.ts index 87458ee..da3262b 100644 --- a/typescript/test/ws.test.ts +++ b/typescript/test/ws.test.ts @@ -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}`); + }); + }); });