feat: protocol evolution compatibility implementation

- Add legacy alias support for backward compatibility
- Update ProtocolBuffer message definitions with new fields
- Implement fullname-based message routing
- Add alias fallback logic in all language clients (Dart, Go, Kotlin, Python, TypeScript)
- Update test suites for alias and fullname validation
- Add protocol sync verification tools
- Update documentation (PORTING_GUIDE, PROTOCOL, VERSIONING)
- Add agent task documentation for protocol evolution
This commit is contained in:
toki 2026-06-16 06:56:15 +09:00
parent 588ac5e95b
commit 6cdd0c3581
35 changed files with 1823 additions and 152 deletions

View file

@ -16,7 +16,7 @@
| 요소 | Go 기준 | 설명 |
|------|---------|------|
| Transport 분리 | `Transport` interface | WritePacket과 Close만 있으면 됨. 소켓 종류(TCP/WS)에 무관하게 Communicator가 동작 |
| typeName 라우팅 | `TypeNameOf()` | protobuf 메시지 이름 기반. 언어마다 추출 방식이 다르므로 PROTOCOL.md의 언어별 표 확인 |
| typeName 라우팅 | `TypeNameOf()` | protobuf full message name 기반. 언어마다 추출 방식이 다르므로 PROTOCOL.md의 언어별 표 확인 |
| nonce 단조 증가 | `atomic.Int32` | 연결당 1에서 시작, 송신마다 +1. thread-safe하게 구현 |
| connCloseOnce | `sync.Once` | Close가 여러 번 불려도 한 번만 수행되어야 함 |
| addListener / addRequestListener 상호 배타 | panic or throw | 같은 typeName에 두 가지 동시 등록 금지. 프로토콜 계약 |
@ -42,7 +42,7 @@
1. 새 언어 디렉터리를 만들기 전에 `agent-ops/skills/project/add-proto-socket-crosstest-language/templates/`의 문서를 복사해 패키지 README와 체크리스트 초안으로 사용한다.
2. `PROTOCOL.md``VERSIONING.md`의 현재 protocol version을 기준으로 지원 범위를 정한다.
3. canonical proto인 `proto/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을 생성한다. 공통 proto package는 `proto_socket`이며, 새 application proto package는 lowercase snake_case segment를 dot으로 연결한 형태만 사용한다. Go처럼 generator 전용 option이 필요하면 언어 패키지 안에 copy를 둘 수 있지만, protobuf package와 message schema는 `tools/check_proto_sync.sh`로 검증 가능해야 한다.
4. `Transport``Communicator`를 먼저 구현하고, 그 위에 TCP/TLS+TCP/WS/WSS client/server를 올린다.
5. 같은 언어 단위 테스트를 작성한 뒤 `agent-ops/skills/project/add-proto-socket-crosstest-language/SKILL.md`의 runner 배치 규칙에 맞춰 양방향 crosstest를 추가한다.
6. README의 Implementations 표를 `Available`로 바꾸기 전에 formatter/linter, 단위 테스트, cross-language tests를 모두 통과시키고, protobuf 외 런타임 의존성이 있다면 native 대안 검토 결과, 기능 범위와 라이브러리 범위가 맞는 이유, 필요성을 문서화한다.
@ -70,7 +70,7 @@
- **Unity 환경**: `System.Threading.Channels`가 Unity 버전에 따라 미지원일 수 있다. 대안으로 `ConcurrentQueue<T>` + ManualResetEvent 조합을 사용한다
- **메인 스레드 제약**: Unity는 UnityEngine API를 메인 스레드에서만 호출할 수 있다. 수신 콜백을 메인 스레드로 dispatch하는 래퍼(`SynchronizationContext`)가 필요하다. Communicator 코어는 스레드 무관하게 유지하고, 래퍼 레이어에서 처리한다
- **protobuf**: `Google.Protobuf` NuGet 패키지 사용. `typeof(T).FullName`이 proto qualified name과 일치하는지 반드시 검증한다. PROTOCOL.md의 typeName 표 참고
- **protobuf**: `Google.Protobuf` NuGet 패키지 사용. descriptor의 full name이 canonical proto full name과 일치하는지 반드시 검증한다. 언어 namespace는 wire identity와 분리한다. PROTOCOL.md의 typeName 표 참고
- **제네릭 헬퍼**: `AddListenerTyped<T>`, `SendRequestTyped<TReq, TRes>` 패턴은 C# 제네릭으로 그대로 구현 가능하다
- **`doClose` 패턴**: C#의 람다 캡처 동작이 Go와 동일하므로 그대로 이식 가능하다
@ -94,7 +94,7 @@
### 주의사항
- **Coroutine Scope 관리**: 클라이언트 생성 시 `CoroutineScope(SupervisorJob() + Dispatchers.IO)`를 만들고, `Close()``scope.cancel()`로 정리한다. SupervisorJob을 사용해야 하위 Job 하나 실패가 전체를 취소하지 않는다
- **protobuf**: `com.google.protobuf:protobuf-kotlin` 사용. typeName은 `descriptor.fullName`으로 추출. PROTOCOL.md 표에서 Kotlin 행 확인
- **protobuf**: `com.google.protobuf:protobuf-kotlin` 사용. typeName은 `descriptor.fullName`으로 추출. Java/Kotlin package option은 generated code namespace일 뿐 wire identity를 바꾸지 않는다. PROTOCOL.md 표에서 Kotlin 행 확인
- **Android 메인 스레드**: Unity와 동일하게 UI 콜백은 `withContext(Dispatchers.Main)`으로 전환한다. Communicator 코어는 IO Dispatcher에서 동작
- **직렬화된 쓰기**: Go의 writeQueue(buffered channel 64) 패턴을 `Channel(capacity = 64)` + 별도 write coroutine으로 구현한다
- **`Self` 타입 전달**: Kotlin 제네릭의 타입 소거(type erasure) 때문에 런타임에 `Self` 인스턴스를 직접 넘겨야 한다. Go의 `self Self` 필드 패턴을 그대로 사용한다
@ -118,7 +118,7 @@
### 주의사항
- **Actor 활용**: Swift 5.5+ actor를 사용하면 mutex/lock 없이 상태 보호가 가능하다. `Communicator`를 actor로 구현하면 `sync.RWMutex` 없이 동일한 안전성을 확보할 수 있다
- **protobuf**: SwiftProtobuf 패키지 사용. typeName은 `Message.protoMessageName`으로 추출. PROTOCOL.md 표에서 Swift 행 확인
- **protobuf**: SwiftProtobuf 패키지 사용. typeName은 `Message.protoMessageName`으로 추출. Swift module/type namespace는 wire identity와 분리한다. PROTOCOL.md 표에서 Swift 행 확인
- **`connCloseOnce` 패턴**: Swift에는 `DispatchOnce`가 deprecated됐다. actor의 상태 변수(`var isClosed = false`) + actor 격리로 대체한다
- **WebSocket**: `URLSessionWebSocketTask` (iOS 13+) 또는 `Network.framework``NWConnection` 사용
- **메모리 관리**: 클로저에서 `[weak self]` 캡처를 빠뜨리면 retain cycle이 발생한다. `doClose`, heartbeat 타이머 클로저 모두 `[weak self]`를 사용한다
@ -144,7 +144,7 @@
- **asyncio 기반**: `async/await` + `asyncio.Queue`로 Go의 goroutine + channel 구조를 근사한다. 동기 API는 제공하지 않는다
- **타입 안전성 한계**: Python에는 런타임 제네릭이 없다. `AddListenerTyped`처럼 완전한 타입 안전 헬퍼 구현이 어렵다. `TypeVar``@overload`로 가능한 범위까지만 표현하고, 나머지는 docstring으로 보완한다
- **protobuf**: `protobuf` 패키지 사용. typeName은 `message.DESCRIPTOR.name`으로 추출. PROTOCOL.md 표에서 Python 행 확인
- **protobuf**: `protobuf` 패키지 사용. typeName은 `message.DESCRIPTOR.full_name`으로 추출. Python module/package path는 wire identity와 분리한다. PROTOCOL.md 표에서 Python 행 확인
- **스레드 안전**: asyncio는 단일 스레드이므로 대부분의 lock이 불필요하다. 단, 멀티스레드 환경에서 쓸 경우 `asyncio.Lock`을 사용한다
- **`connCloseOnce` 패턴**: bool flag + asyncio.Lock으로 구현한다. `asyncio.Event``set()`은 멱등하므로 활용 가능하다
- **에러 처리**: Go의 명시적 error 반환 대신 exception을 사용하되, public API에서는 구체 exception 타입을 정의해서 문서화한다
@ -168,7 +168,7 @@
### 주의사항
- **크로스 환경 (Browser & Node.js)**: 코어 로직(`Communicator`, `BaseClient`)은 런타임 환경에 독립적으로 작성한다. 브라우저에서는 내장 `WebSocket`을 사용하고, Node.js에서는 `net`, `tls`, `http`, `https` 등 Node.js 내장 모듈 기반 Transport를 사용한다. TypeScript 런타임에 외부 WebSocket 패키지를 추가하지 않는다
- **protobuf**: protobuf는 핵심 프로토콜 계층이므로 예외로 취급한다. 현재 TypeScript 구현은 canonical schema에 맞춘 native codec을 사용하지만, protobuf binding/runtime을 도입해야 한다면 프로토콜 계층 용도로만 제한한다. `typeName`은 로컬 `MessageType.typeName` 값이 `PROTOCOL.md` 표와 일치해야 한다
- **protobuf**: protobuf는 핵심 프로토콜 계층이므로 예외로 취급한다. 현재 TypeScript 구현은 canonical schema에 맞춘 native codec을 사용하지만, protobuf binding/runtime을 도입해야 한다면 프로토콜 계층 용도로만 제한한다. `typeName`은 로컬 `MessageType.typeName` 값이 canonical proto full name 및 `PROTOCOL.md` 표와 일치해야 한다
- **단일 스레드 (Event Loop)**: JS/TS는 단일 스레드 기반이므로 메모리 동시 접근에 대한 Mutex/Lock(`sync.RWMutex`)은 불필요하다. 단, 비동기 컨텍스트(await) 간의 논리적 상태 오염은 주의한다
- **바이너리 처리**: Node.js의 `Buffer` 대신 표준 웹 API인 `Uint8Array`를 기준으로 작성하여 브라우저 환경 호환성을 확보한다
- **`connCloseOnce` 패턴**: 단순 `isClosed: boolean` 플래그로 멱등성을 보장한다. 만약 `Close()` 내에 `await`가 포함될 경우 중복 실행(re-entrancy) 방지에 유의한다

View file

@ -50,7 +50,7 @@ This protocol intentionally defines only the transport-level contract shared acr
### PacketBase
```protobuf
message PacketBase {
string typeName = 1; // protobuf message name used for routing
string typeName = 1; // protobuf full message name used for routing
int32 nonce = 2; // monotonically increasing per-connection counter
bytes data = 3; // serialized inner message bytes
int32 responseNonce = 4; // > 0: this packet is a response to request with that nonce
@ -59,7 +59,7 @@ message PacketBase {
| Field | Description |
|-------|-------------|
| `typeName` | Protobuf message name used as the language-agnostic routing key |
| `typeName` | Protobuf full message name used as the language-agnostic routing key |
| `nonce` | Starts at 1, increments by 1 per send call per connection |
| `data` | Protobuf-serialized bytes of the inner message |
| `responseNonce` | `0` for regular messages. Set to the request's `nonce` when sending a response |
@ -103,24 +103,29 @@ Changing heartbeat timing semantics, response behavior, or disconnect rules is a
## typeName Convention
Uses the protobuf message name on the send side.
On the receive side, use the same value as the registration key.
`typeName` uses the protobuf full message name on the send side.
For messages in the common proto package, the canonical values are `proto_socket.PacketBase`, `proto_socket.HeartBeat`, and `proto_socket.TestData`; `PacketBase.typeName` normally carries the inner message name such as `proto_socket.HeartBeat` or `proto_socket.TestData`.
On the receive side, use the same canonical value as the registration key.
Proto package names are part of the wire identity. Package names must use lowercase snake_case segments joined by dots, for example `proto_socket` or `example_chat.v1`. Language-specific namespace options such as Go `go_package` or Java/Kotlin package names do not change the wire `typeName`.
The canonical package for `proto/message_common.proto` is `proto_socket`.
| Language | Registration key |
|----------|-----------------|
| Dart | `T.toString()` (equals qualified name for top-level proto messages) |
| Dart | protobuf descriptor/native full name when available, otherwise static metadata aligned with the canonical full name |
| Go | `string(proto.MessageName(m))` via `TypeNameOf(m)` |
| C# | `typeof(T).Name` — verify matches proto qualified name |
| C# | protobuf descriptor full name — verify matches the canonical full name |
| Kotlin | `descriptorForType.fullName` via `typeNameOf(m)` |
| Swift | `String(describing: T.self)` — verify matches |
| TypeScript | local `MessageType.typeName` value — verify matches |
| Python | `descriptor.name` from `MessageClass.DESCRIPTOR` — verify matches |
| Swift | `Message.protoMessageName` or equivalent descriptor full name — verify matches the canonical full name |
| TypeScript | local `MessageType.typeName` value — verify matches the canonical full name |
| Python | `MessageClass.DESCRIPTOR.full_name` |
The current packet proto has no `package` declaration, so available implementations use simple names such as `TestData` and `HeartBeat`. If a future proto adds a `package`, language runtimes may derive fully qualified names, and all implementations must use the same value.
Implementations may accept legacy simple-name values such as `TestData` and `HeartBeat` for receive compatibility, but new sends must use the canonical full proto name once the implementation supports this convention.
**Important**: Verify typeName consistency across languages before connecting heterogeneous clients.
Changing the `typeName` derivation rule is a breaking protocol change unless all released implementations keep a compatibility path. See [VERSIONING.md](VERSIONING.md).
Changing the proto package or `typeName` derivation rule is a breaking protocol change unless all released implementations keep a compatibility path. See [VERSIONING.md](VERSIONING.md).
---
@ -211,8 +216,9 @@ A single receive coordinator dequeues packets one at a time and dispatches them:
Sending `HeartBeat {}`:
```
00 00 00 0B ← header: PacketBase length = 11 bytes
0A 09 48 65 61 72 74 42 65 61 74 ← PacketBase { typeName: "HeartBeat" }
00 00 00 18 ← header: PacketBase length = 24 bytes
0A 16 70 72 6F 74 6F 5F 73 6F 63 6B 65 74 2E 48 65 61 72 74 42 65 61 74
← PacketBase { typeName: "proto_socket.HeartBeat" }
```
---
@ -236,4 +242,6 @@ Sending `HeartBeat {}`:
`proto/message_common.proto` is the canonical packet definition.
All language implementations generate bindings from it.
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.
The canonical package for this proto is `proto_socket`. New application protos must choose package names using lowercase snake_case segments joined by dots, and those package names define the wire `typeName` prefix.
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/`. These options affect generated code namespaces only; they do not change the protobuf package or wire identity. Keep those copies' message schemas in sync with `proto/message_common.proto` before regenerating language bindings.

View file

@ -16,13 +16,21 @@ The protocol version describes the wire-format and behavior contract that every
- TCP uses a 4-byte big-endian payload length followed by one `PacketBase` protobuf payload.
- WebSocket and WSS use one binary frame per `PacketBase` protobuf payload.
- `PacketBase.typeName` routes the inner message.
- `PacketBase.typeName` routes the inner message by canonical protobuf full message name.
- `nonce` increments for every outbound packet on a connection.
- `responseNonce` links a response packet to the request nonce.
- Heartbeat behavior follows `PROTOCOL.md`.
Breaking protocol changes require a new major protocol version. Backward-compatible additions keep the same major protocol version but must include updated tests before release.
## Proto Package and Wire Identity
Proto package names are part of the wire identity because they prefix protobuf full message names. Package names must use lowercase snake_case segments joined by dots, for example `proto_socket` or `example_chat.v1`.
The canonical package for the shared packet schema is `proto_socket`. Common message identities therefore use values such as `proto_socket.PacketBase`, `proto_socket.HeartBeat`, and `proto_socket.TestData`; `PacketBase.typeName` carries the inner message identity.
Language-specific namespace options such as Go `go_package` or Java/Kotlin package names are package-generation details. They do not change the protocol package or wire `typeName`.
## Git Release Standard
Current consumption and release flow is Git ref/tag based. Package registry publication is optional future work, not the current release standard.
@ -40,7 +48,7 @@ A package release must state which protocol version it implements. A package ver
Examples of breaking protocol changes:
- Renaming or removing `PacketBase` fields.
- Changing `typeName` from simple proto names to fully-qualified names without a compatibility path.
- Changing the canonical proto package or `typeName` derivation without a compatibility path.
- Changing `nonce` or `responseNonce` semantics.
- Changing heartbeat request/response timing or echo behavior in a way that disconnects older peers.
- Changing TCP framing, byte order, max packet handling, or WebSocket binary-frame rules.
@ -74,7 +82,8 @@ Changing this wrap behavior or the reserved meaning of `responseNonce == 0` chan
A new language implementation must target the current protocol version unless its README explicitly says otherwise. Before it is listed as available, it must:
- Generate protobuf bindings from the canonical schema.
- Use the same `typeName` values as the available implementations.
- Use the same canonical protobuf full message name `typeName` values as the available implementations.
- Keep language namespace/package options separate from the protobuf package used for wire identity.
- Implement TCP, TLS+TCP, WS, and WSS framing consistently where the language runtime supports them.
- Implement request-response correlation with `nonce` and `responseNonce`.
- Handle heartbeat automatically inside the client/server core.

View file

@ -10,7 +10,7 @@
## 상태
[계획]
[진행중]
## 승격 조건
@ -49,7 +49,7 @@
wire message identity를 full proto name으로 정렬하고, legacy simple name 수신 호환성을 유지한다.
- [ ] [pkg-rule] Proto package naming rule을 `PROTOCOL.md`, `VERSIONING.md`, `PORTING_GUIDE.md`에 문서화한다. package는 lowercase snake_case segment를 dot으로 연결한 형태만 허용하고, 언어별 namespace/package option은 wire identity와 분리한다. common proto의 package는 `proto_socket`으로 둔다.
- [x] [pkg-rule] Proto package naming rule을 `PROTOCOL.md`, `VERSIONING.md`, `PORTING_GUIDE.md`에 문서화한다. package는 lowercase snake_case segment를 dot으로 연결한 형태만 허용하고, 언어별 namespace/package option은 wire identity와 분리한다. common proto의 package는 `proto_socket`으로 둔다.
- [ ] [proto-package-sync] Canonical `proto/message_common.proto``package proto_socket;`을 추가하고 언어별 proto copy와 generated binding을 동기화한다. 검증: `tools/check_proto_sync.sh`
- [ ] [fullname-type] 각 사용 가능 언어의 `typeNameOf()` 또는 message metadata를 full proto name 기준으로 정렬한다. descriptor/native metadata를 우선 사용하되, Python은 `DESCRIPTOR.full_name`, TypeScript는 static metadata를 사용한다. common proto의 기대값은 `proto_socket.<MessageName>`이다.
- [ ] [legacy-alias] 수신 type registry에 full name과 simple name alias를 함께 등록하되, parser lookup, listener/request handler routing, response type matching, pending expected type 비교가 같은 alias 규칙을 사용하게 한다. simple alias 충돌 시 초기화 실패로 처리한다.

View file

@ -0,0 +1,174 @@
<!-- task=m-protocol-evolution-compatibility/01_proto_fullname_foundation plan=0 tag=FOUNDATION -->
# Code Review Reference - FOUNDATION
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-15
task=m-protocol-evolution-compatibility/01_proto_fullname_foundation, plan=0, tag=FOUNDATION
## Roadmap Targets
- Milestone: `agent-roadmap/milestones/protocol-evolution-compatibility.md`
- Task ids:
- `proto-package-sync`: Canonical proto package 추가와 언어별 proto/generated binding 동기화
- `fullname-type`: 사용 가능 언어의 `typeName` 또는 message metadata를 full proto name 기준으로 정렬
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 plan 스킬의 종결 순서를 따른다.
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [FOUNDATION-1] Proto Package Sync | [x] |
| [FOUNDATION-2] Canonical Type Metadata | [x] |
| [FOUNDATION-3] Immediate Test Expectations | [x] |
## 구현 체크리스트
- [x] `proto/message_common.proto`에 `package proto_socket;`을 추가하고 Go/Kotlin/Python proto copy와 generated binding을 동기화한다.
- [x] Dart/Go/Kotlin/Python/TypeScript의 기본 송신 `typeName`과 static/native metadata 기대값을 `proto_socket.<MessageName>`으로 맞춘다.
- [x] simple-name을 직접 기대하는 즉시 실패 테스트와 helper를 canonical full name 기준으로 갱신한다.
- [x] `tools/generate_proto.sh`, `tools/check_proto_sync.sh`, 각 언어 focused unit test, 전체 matrix를 실행하거나 실행 불가 근거를 기록한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G06_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리를 archive로 이동한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고하고 roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent 유지/제거 상태를 확인한다.
## 계획 대비 변경 사항
- **Python 바인딩 자동 갱신 보강**: `tools/generate_proto.sh` 에 Python 용 protoc 생성 커맨드를 보강하여, proto 변경 후 `tools/generate_proto.sh` 하나로 Dart, Go, Python의 generated binding이 함께 갱신되도록 개선하였습니다.
- **Dart typeName lookup 유연화**: `_qualifiedMessageNameOf<T>` helper를 도입하여 generic type의 클래스명과 `_instanceGenerator`에 설정된 식별자(custom 패키지명 포함)를 맵과 연동하여 동적으로 가져오도록 개선하여 기존 커스텀 package-qualified 라우팅 테스트와 완벽하게 호환되도록 하였습니다.
## 주요 설계 결정
- **Python `type_name_of` metadata**: `descriptor.name` 대신 `descriptor.full_name`을 사용하여 canonical proto name인 `proto_socket.<MessageName>`이 기본 타입명으로 송출되게 수정하였습니다.
- **TypeScript static schema**: `typescript/src/packets/message_common_pb.ts` 내의 static PacketBase, HeartBeat, TestData 스키마의 `typeName` 메타데이터에 `proto_socket.` 프리픽스를 고정하여 수동 인코딩과의 정합성을 보장했습니다.
- **Dart type lookup mapping**: generic type parameter의 default instance를 dynamic하게 찾아 `qualifiedMessageName`을 dynamic resolving 함으로써 type.toString()과 wire package name 간의 안전한 fallback 체계를 유지했습니다.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Generated binding이 수동 편집 drift 없이 proto package를 반영하는지 확인한다.
- Python/TypeScript full name 값이 `proto_socket.<MessageName>`으로 고정됐는지 확인한다.
- `tools/check_proto_sync.sh`가 Python proto copy까지 놓치지 않는지 확인한다.
- 전체 matrix 결과가 실제 변경 파일과 같은 worktree에서 실행됐는지 확인한다.
## 검증 결과
### FOUNDATION-1 중간 검증
```bash
$ tools/generate_proto.sh
Proto schemas are in sync.
$ tools/check_proto_sync.sh
Proto schemas are in sync.
```
### FOUNDATION-2 중간 검증
```bash
$ dart test test/communicator_test.dart
00:00 +0: loading test/communicator_test.dart
...
00:00 +28: All tests passed!
$ go test ./...
ok git.toki-labs.com/toki/proto-socket/go 0.049s
ok git.toki-labs.com/toki/proto-socket/go/test 8.282s
$ cd kotlin && ./gradlew test
BUILD SUCCESSFUL in 33s
$ cd python && python3 -m pytest -q
........................................... [100%]
43 passed in 1.24s
$ cd typescript && npm run check && npm test
✓ test/base_client.test.ts (7 tests) 11ms
✓ test/browser_ws_client.test.ts (7 tests) 31ms
✓ test/tcp.test.ts (7 tests) 271ms
✓ test/communicator.test.ts (30 tests) 651ms
✓ test/ws.test.ts (12 tests) 1434ms
Test Files 5 passed (5)
Tests 63 passed (63)
```
### 최종 검증
```bash
$ bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all
**Proto 동기화**
| 검사 | 명령 | 결과 |
|---|---|---|
| schema sync | `tools/check_proto_sync.sh` | PASS |
**동일언어**
| 언어 | 명령 | 결과 |
|---|---|---|
| Dart | `dart pub get && dart test && dart compile js test/browser_ws_import_compile.dart -o /tmp/proto_socket_browser_ws_import_compile.js` | PASS |
| Go | `go test ./...` | PASS |
| Kotlin | `./gradlew test` | PASS |
| Python | `python3 -m pytest -q` | PASS |
| TypeScript | `npm run check && npm test` | PASS |
**언어 PASS 매트릭스**
| 서버 \ 클라이언트 | Dart.io | Dart.web | Dart.web(WSS) | Go | Kotlin | Python | TypeScript |
|---|---|---|---|---|---|---|---|
| Dart.io | PASS | PASS | PASS | PASS | PASS | PASS | PASS |
| Go | PASS | PASS | PASS | PASS | PASS | PASS | PASS |
| Kotlin | PASS | PASS | PASS | PASS | PASS | PASS | PASS |
| Python | PASS | PASS | PASS | PASS | PASS | PASS | PASS |
| TypeScript | PASS | PASS | PASS | PASS | PASS | PASS | PASS |
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- Correctness: Pass
- Completeness: Pass
- Test coverage: Pass
- API contract: Pass
- Code quality: Pass
- Plan deviation: Pass
- Verification trust: Pass
- 발견된 문제:
- Nit (수정 완료) `PROTOCOL.md:219` heartbeat wire 예시가 canonical full name 전환 후에도 11-byte `"HeartBeat"` simple name payload를 보여주고 있었다. 리뷰 중 `proto_socket.HeartBeat`와 맞는 24-byte envelope 예시로 갱신했다.
- 다음 단계: PASS이므로 active plan/review를 로그로 아카이브하고 `complete.log` 작성 후 split task 디렉터리를 `agent-task/archive/YYYY/MM/m-protocol-evolution-compatibility/01_proto_fullname_foundation/`로 이동한다.

View file

@ -0,0 +1,48 @@
# Complete - m-protocol-evolution-compatibility/01_proto_fullname_foundation
## 완료 일시
2026-06-15T12:53:17Z
## 요약
FOUNDATION plan 0 first review completed with PASS; proto package sync and canonical full-name type metadata are implemented and verified.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G06_0.log` | `code_review_cloud_G06_0.log` | PASS | Required/Suggested issues 없음; stale protocol heartbeat example Nit was repaired during review. |
## 구현/정리 내용
- Added canonical `package proto_socket;` to the shared proto and language proto copies, then regenerated Dart, Go, and Python bindings.
- Aligned Dart, Go, Kotlin, Python, and TypeScript send/type metadata paths to canonical `proto_socket.<MessageName>` values.
- Updated immediate full-name expectations in focused tests and extended proto sync tooling to include the Python proto copy.
- Review cleanup: updated the `PROTOCOL.md` heartbeat wire example to show the 24-byte `proto_socket.HeartBeat` envelope.
## 최종 검증
- `tools/generate_proto.sh && tools/check_proto_sync.sh` - PASS; output: `Proto schemas are in sync.` twice.
- `dart test test/communicator_test.dart` - PASS; 28 tests passed.
- `go test ./...` - PASS; root and `go/test` packages passed.
- `cd kotlin && ./gradlew test` - PASS; BUILD SUCCESSFUL.
- `cd python && python3 -m pytest -q` - PASS; 43 passed.
- `cd typescript && npm run check && npm test` - PASS; 63 tests passed.
- `bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all` - PASS; result record `agent-test/runs/20260615-124648-proto-socket-full-matrix.md`.
## Roadmap Completion
- Milestone: `agent-roadmap/milestones/protocol-evolution-compatibility.md`
- Completed task ids:
- `proto-package-sync`: PASS; evidence=`agent-task/archive/2026/06/m-protocol-evolution-compatibility/01_proto_fullname_foundation/plan_cloud_G06_0.log`, `agent-task/archive/2026/06/m-protocol-evolution-compatibility/01_proto_fullname_foundation/code_review_cloud_G06_0.log`; verification=`tools/generate_proto.sh && tools/check_proto_sync.sh`, `bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all`
- `fullname-type`: PASS; evidence=`agent-task/archive/2026/06/m-protocol-evolution-compatibility/01_proto_fullname_foundation/plan_cloud_G06_0.log`, `agent-task/archive/2026/06/m-protocol-evolution-compatibility/01_proto_fullname_foundation/code_review_cloud_G06_0.log`; verification=`dart test test/communicator_test.dart`, `go test ./...`, `cd kotlin && ./gradlew test`, `cd python && python3 -m pytest -q`, `cd typescript && npm run check && npm test`, `bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,230 @@
<!-- task=m-protocol-evolution-compatibility/01_proto_fullname_foundation plan=0 tag=FOUNDATION -->
# Plan - FOUNDATION
## 이 파일을 읽는 구현 에이전트에게
이 plan은 `proto-package-sync`와 `fullname-type`을 함께 끝내는 foundation 작업이다. 구현 후 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션에 실제 변경 내용, 실행한 검증 출력, 계획 대비 변경 사항을 채워야 하며, finalization은 code-review 스킬 전용이다. 사용자 결정, 사용자 소유 외부 환경, 범위 충돌이 아니면 사용자에게 직접 묻지 말고 review stub의 `사용자 리뷰 요청` 섹션에 근거를 남긴다. 재실행이나 증거 수집으로 해소 가능한 검증 공백은 사용자 리뷰 요청이 아니다.
## 배경
현재 공통 proto에는 package가 없고 일부 구현은 simple name을 전제로 한다. 마일스톤 결정은 canonical package `proto_socket`과 full proto name 기반 송신이므로, proto/package sync와 각 언어의 `typeName` 산출을 한 번에 맞춰야 중간 상태에서 테스트가 깨지지 않는다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 채팅으로 직접 질문하거나 선택지를 제시하지 않는다. code-review가 blocker 정당성을 판단하고 필요할 때만 `USER_REVIEW.md`를 쓴다.
## Roadmap Targets
- Milestone: `agent-roadmap/milestones/protocol-evolution-compatibility.md`
- Task ids:
- `proto-package-sync`: Canonical proto package 추가와 언어별 proto/generated binding 동기화
- `fullname-type`: 사용 가능 언어의 `typeName` 또는 message metadata를 full proto name 기준으로 정렬
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/update-roadmap/SKILL.md`
- `agent-ops/rules/project/domain/protocol/rules.md`
- `agent-ops/rules/project/domain/tools/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/protocol-smoke.md`
- `agent-test/local/tools-smoke.md`
- `agent-test/local/proto-socket-full-matrix.md`
- `agent-roadmap/current.md`
- `agent-roadmap/milestones/protocol-evolution-compatibility.md`
- `PROTOCOL.md`, `VERSIONING.md`, `PORTING_GUIDE.md`
- `proto/message_common.proto`
- `go/packets/message_common.proto`, `go/packets/message_common.pb.go`, `go/communicator.go`, `go/test/test_helpers_test.go`, `go/test/communicator_test.go`
- `kotlin/src/main/proto/message_common.proto`, `kotlin/src/main/kotlin/com/tokilabs/proto_socket/Communicator.kt`, `kotlin/src/test/kotlin/com/tokilabs/proto_socket/TestHelpers.kt`, `kotlin/src/test/kotlin/com/tokilabs/proto_socket/CommunicatorTest.kt`
- `python/proto_socket/packets/message_common.proto`, `python/proto_socket/packets/message_common_pb2.py`, `python/proto_socket/communicator.py`, `python/test/test_communicator.py`, `python/test/test_tcp.py`
- `typescript/src/packets/message_common_pb.ts`, `typescript/src/communicator.ts`, `typescript/test/communicator.test.ts`, `typescript/test/base_client.test.ts`, `typescript/test/tcp.test.ts`
- `dart/lib/src/communicator.dart`, `dart/lib/src/protobuf_client.dart`, `dart/lib/src/ws_protobuf_client_io.dart`, `dart/lib/src/ws_protobuf_client_web.dart`, `dart/lib/src/heartbeat_mixin.dart`, `dart/test/communicator_test.dart`
- `tools/generate_proto.sh`, `tools/check_proto_sync.sh`, `README.md`
### 테스트 환경 규칙
`test_env=local`로 판단했다. `agent-test/local/rules.md`를 읽었고, protocol smoke는 `tools/check_proto_sync.sh`, tools smoke는 `tools/check_proto_sync.sh`, public protocol/API 영향은 `bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all`로 확장해야 한다. 구조가 비어 있거나 `<확인 필요>`인 필드는 없었다. `tools/generate_proto.sh`는 proto 변경 후 필수이며, 생성 도구 부재 시 `command -v protoc`, `command -v protoc-gen-dart`, `command -v protoc-gen-go` 결과를 review stub에 기록한다.
### 테스트 커버리지 공백
- Proto package 추가 후 generated full name 변화는 기존 tests 일부가 simple name을 기대해 실패한다.
- `go/test/test_helpers_test.go:20`은 `TypeNameOf(TestData) == "TestData"`를 기대한다.
- `python/test/test_communicator.py:51`은 송신 `typeName == "TestData"`를 기대한다.
- TypeScript static packet metadata는 `typescript/src/packets/message_common_pb.ts:35-70`에서 simple name을 hard-code한다.
- Dart는 package-qualified 수신 테스트가 있으나 canonical package `proto_socket` 전환 후 typed registration 기대값을 재확인해야 한다.
### 심볼 참조
- 변경 예정: `type_name_of` 반환값(`python/proto_socket/communicator.py:539-543`), TypeScript `MessageType.typeName`/`$typeName` literal, generated protobuf descriptors.
- 제거 예정 symbol 없음.
- call site: parser maps and tests in `go/test/**`, `kotlin/src/test/**`, `python/test/**`, `typescript/test/**`, crosstest parser maps under `*/crosstest/**`.
### 분할 판단
분할 정책을 평가했다. 공통 task group은 `agent-task/m-protocol-evolution-compatibility/`다. `01_proto_fullname_foundation`은 독립 선행 작업이며 predecessor 없음. `02+01_legacy_alias`는 full-name foundation 완료 후 alias registry를 구현한다. `03+01,02_fullname_tests`는 foundation과 alias 둘 다 완료된 뒤 동일/크로스 테스트를 확장한다. `proto-package-sync`와 `fullname-type`은 따로 분리하면 generated descriptor가 바뀐 중간 상태에서 기존 tests가 깨지므로 하나의 plan으로 묶는다.
### 범위 결정 근거
이 plan은 package 선언, generated binding sync, canonical full-name 송신/metadata 정렬까지만 다룬다. legacy simple-name receive alias, alias collision policy, 크로스 언어 legacy 호환 테스트 추가는 후속 split plan의 범위다. evolution 에픽(`hello-cap`, `std-error`, `nonce-boundary`, `ordering-doc`)은 수정하지 않는다.
### 빌드 등급
`cloud-G06`: proto/schema와 다중 언어 generated binding, public protocol identity가 함께 바뀌어 review context가 넓다.
## 구현 체크리스트
- [ ] `proto/message_common.proto`에 `package proto_socket;`을 추가하고 Go/Kotlin/Python proto copy와 generated binding을 동기화한다.
- [ ] Dart/Go/Kotlin/Python/TypeScript의 기본 송신 `typeName`과 static/native metadata 기대값을 `proto_socket.<MessageName>`으로 맞춘다.
- [ ] simple-name을 직접 기대하는 즉시 실패 테스트와 helper를 canonical full name 기준으로 갱신한다.
- [ ] `tools/generate_proto.sh`, `tools/check_proto_sync.sh`, 각 언어 focused unit test, 전체 matrix를 실행하거나 실행 불가 근거를 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [FOUNDATION-1] Proto Package Sync
문제: canonical proto와 언어별 proto copy에 package가 없다.
```protobuf
// proto/message_common.proto:1-3
syntax = "proto3";
message PacketBase {
```
해결 방법: canonical proto는 `syntax` 다음에 `package proto_socket;`을 추가한다. Go/Kotlin copy는 언어별 option 위에 같은 package를 추가하고, Python copy도 동기화한다. 그 뒤 generated bindings를 재생성한다. `tools/check_proto_sync.sh`는 Python proto copy도 normalize/compare하도록 확장한다.
수정 파일 및 체크리스트:
- [ ] `proto/message_common.proto`
- [ ] `go/packets/message_common.proto`
- [ ] `kotlin/src/main/proto/message_common.proto`
- [ ] `python/proto_socket/packets/message_common.proto`
- [ ] `dart/lib/src/packets/message_common.pb*.dart`
- [ ] `go/packets/message_common.pb.go`
- [ ] `python/proto_socket/packets/message_common_pb2.py`, `python/proto_socket/packets/message_common_pb2.pyi` if regenerated
- [ ] `typescript/src/packets/message_common_pb.ts`
- [ ] `tools/check_proto_sync.sh`
테스트 작성: 생성물 정합성은 script로 검증한다. 별도 test 파일보다 `tools/check_proto_sync.sh`에 Python copy mismatch 실패 경로를 포함한다.
중간 검증:
```bash
tools/generate_proto.sh
tools/check_proto_sync.sh
```
### [FOUNDATION-2] Canonical Type Metadata
문제: 일부 언어는 full name이 아니라 simple/static name을 사용한다.
```python
# python/proto_socket/communicator.py:539-543
def type_name_of(message_or_class: Message | type[Message]) -> str:
descriptor = getattr(message_or_class, "DESCRIPTOR", None)
if descriptor is None:
descriptor = message_or_class.__class__.DESCRIPTOR
return descriptor.name
```
```typescript
// typescript/src/packets/message_common_pb.ts:35-70
export const PacketBaseSchema: MessageType<PacketBase> = {
typeName: "PacketBase",
...
export const TestDataSchema: MessageType<TestData> = {
typeName: "TestData",
```
해결 방법: Python은 `descriptor.full_name`을 반환한다. TypeScript는 `$typeName` literal과 `MessageType.typeName`을 `proto_socket.PacketBase`, `proto_socket.HeartBeat`, `proto_socket.TestData`로 바꾼다. Dart typed registration은 generated `qualifiedMessageName`과 일치하는 canonical helper를 사용하도록 조정한다. Go/Kotlin은 package 추가 후 descriptor 기반 값이 full name인지 테스트로 고정한다.
수정 파일 및 체크리스트:
- [ ] `python/proto_socket/communicator.py`
- [ ] `typescript/src/packets/message_common_pb.ts`
- [ ] `dart/lib/src/communicator.dart`
- [ ] `dart/lib/src/protobuf_client.dart`
- [ ] `dart/lib/src/ws_protobuf_client_io.dart`
- [ ] `dart/lib/src/ws_protobuf_client_web.dart`
- [ ] `dart/lib/src/heartbeat_mixin.dart`
테스트 작성: 즉시 기대값 테스트를 각 언어 unit에 반영한다. alias/legacy receive 테스트는 `03+01,02_fullname_tests`에서 확장한다.
중간 검증:
```bash
dart test test/communicator_test.dart
go test ./...
cd kotlin && ./gradlew test
cd python && python3 -m pytest -q
cd typescript && npm run check && npm test
```
### [FOUNDATION-3] Immediate Test Expectations
문제: tests/helper가 simple name을 canonical 값으로 고정하고 있다.
```go
// go/test/test_helpers_test.go:20-23
func TestTypeNameMatchesDartConvention(t *testing.T) {
if got := toki.TypeNameOf(&packets.TestData{}); got != "TestData" {
t.Fatalf("type name = %q, want TestData", got)
```
```python
# python/test/test_communicator.py:48-52
await communicator.send(ProtoTestData(index=7, message="hello"))
assert len(transport.packets) == 1
packet = transport.packets[0]
assert packet.typeName == "TestData"
```
해결 방법: 기대값을 `proto_socket.TestData`로 바꾸고, HeartBeat/TestData parser maps가 canonical key로 초기화되는지 확인한다. crosstest의 INFO 출력은 값이 바뀌어도 runner가 특정 simple string을 파싱하지 않는지 확인만 한다.
수정 파일 및 체크리스트:
- [ ] `go/test/test_helpers_test.go`
- [ ] `python/test/test_communicator.py`
- [ ] `typescript/test/communicator.test.ts`
- [ ] `typescript/test/base_client.test.ts`
- [ ] `kotlin/src/test/kotlin/com/tokilabs/proto_socket/CommunicatorTest.kt`
- [ ] `dart/test/communicator_test.dart`
테스트 작성: 기존 assertion 갱신이며 새 behavior 추가는 후속 tests plan에서 한다.
중간 검증:
```bash
rg --sort path '"TestData"|typeName == "TestData"|want TestData' dart go kotlin python typescript
```
## 수정 파일 요약
| 파일 | 항목 |
|---|---|
| `proto/message_common.proto`, `go/packets/message_common.proto`, `kotlin/src/main/proto/message_common.proto`, `python/proto_socket/packets/message_common.proto` | FOUNDATION-1 |
| `dart/lib/src/packets/*`, `go/packets/message_common.pb.go`, `python/proto_socket/packets/message_common_pb2.py`, `typescript/src/packets/message_common_pb.ts` | FOUNDATION-1, FOUNDATION-2 |
| `dart/lib/src/communicator.dart`, `python/proto_socket/communicator.py`, `typescript/src/communicator.ts` | FOUNDATION-2 |
| `tools/check_proto_sync.sh` | FOUNDATION-1 |
| `dart/test/communicator_test.dart`, `go/test/test_helpers_test.go`, `kotlin/src/test/kotlin/com/tokilabs/proto_socket/CommunicatorTest.kt`, `python/test/test_communicator.py`, `typescript/test/communicator.test.ts` | FOUNDATION-3 |
## 최종 검증
```bash
tools/generate_proto.sh
tools/check_proto_sync.sh
dart test test/communicator_test.dart
go test ./...
cd kotlin && ./gradlew test
cd python && python3 -m pytest -q
cd typescript && npm run check && npm test
bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all
```
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,137 @@
<!-- task=m-protocol-evolution-compatibility/02+01_legacy_alias plan=0 tag=ALIAS -->
# Code Review Reference - ALIAS
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation.
> Finalization is review-agent-only.
## 개요
date=2026-06-15
task=m-protocol-evolution-compatibility/02+01_legacy_alias, plan=0, tag=ALIAS
## Roadmap Targets
- Milestone: `agent-roadmap/milestones/protocol-evolution-compatibility.md`
- Task ids:
- `legacy-alias`: parser lookup, listener/request handler routing, response type matching, pending expected type 비교에 동일 alias 규칙 적용
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
리뷰 완료는 plan 스킬의 종결 순서를 따른다. 이 dependent split은 predecessor complete.log 확인 여부를 특히 검토한다.
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [ALIAS-1] Alias Registry Helpers | [x] |
| [ALIAS-2] Routing And Pending Matching | [x] |
| [ALIAS-3] HeartBeat Alias Consistency | [x] |
## 구현 체크리스트
- [x] 구현 시작 전 `01_proto_fullname_foundation` complete.log로 predecessor 완료를 확인한다.
- [x] 각 언어에 canonical full name과 legacy simple name을 같은 identity group으로 다루는 helper를 추가한다.
- [x] parser map 등록 시 full/simple alias를 모두 등록하고, simple alias가 서로 다른 full name으로 충돌하면 initialize 단계에서 실패시킨다.
- [x] listener/request handler 등록과 상호 배타 검사, inbound dispatch, pending response expected type 비교가 같은 alias 규칙을 사용하게 한다.
- [x] 각 언어 focused unit test로 simple legacy receive와 alias collision을 검증한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] active files를 `.log`로 아카이브하고 PASS이면 `complete.log`를 작성한다.
- [ ] PASS이면 active task directory를 archive로 이동한다.
- [ ] PASS split 작업이면 dependency와 parent directory 상태를 확인한다.
- [ ] roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
## 계획 대비 변경 사항
- 없음. 계획된 모든 설계 요구사항에 맞춰 5개 언어 구현체에 동일한 Alias 처리 로직을 적용 완료함.
## 주요 설계 결정
- **Centralized Canonical Name Map**: 각 언어의 `Communicator` 내부에 `canonicalNameMap`을 캐시하여, full name(패키지명 포함)과 simple name(alias)을 단일 identity group으로 묶어 O(1)로 lookup하도록 일관되게 구조화했습니다.
- **Strict Collision Verification**: 초기화(`initialize`) 단계에서 `parserMap`을 병렬 순회하며 simple alias가 서로 다른 두 full name에 매핑되어 충돌하는 경우 `IllegalArgumentException`/`ValueError`/`panic` 등으로 즉시 실패하도록 예외 제약을 추가했습니다.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Parser alias와 handler/listener alias가 서로 다른 helper를 쓰지 않는지 확인한다.
- Pending response expected type 비교가 simple/full 양쪽을 같은 identity로 보는지 확인한다.
- Simple alias collision이 초기화 단계에서 실패하는지 확인한다.
- HeartBeat 자동 등록이 alias helper를 통과하는지 확인한다.
## 검증 결과
### ALIAS-1 중간 검증
```bash
$ go test ./...
ok git.toki-labs.com/toki/proto-socket/go 0.043s
ok git.toki-labs.com/toki/proto-socket/go/test 8.312s
$ cd kotlin && ./gradlew test
BUILD SUCCESSFUL in 15s
$ cd python && python3 -m pytest -q
............................................. [100%]
45 passed in 1.56s
$ cd typescript && npm run check && npm test
✓ test/base_client.test.ts (7 tests) 10ms
✓ test/browser_ws_client.test.ts (7 tests) 40ms
✓ test/tcp.test.ts (7 tests) 271ms
✓ test/communicator.test.ts (32 tests) 601ms
✓ test/ws.test.ts (12 tests) 1460ms
Test Files 5 passed (5)
Tests 65 passed (65)
$ dart test test/communicator_test.dart
00:00 +30: All tests passed!
```
### 최종 검증
```bash
$ bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all
**Proto 동기화**
| 검사 | 명령 | 결과 |
|---|---|---|
| schema sync | `tools/check_proto_sync.sh` | PASS |
**동일언어**
| 언어 | 명령 | 결과 |
|---|---|---|
| Dart | `dart pub get && dart test && dart compile js test/browser_ws_import_compile.dart -o /tmp/proto_socket_browser_ws_import_compile.js` | PASS |
| Go | `go test ./...` | PASS |
| Kotlin | `./gradlew test` | PASS |
| Python | `python3 -m pytest -q` | PASS |
| TypeScript | `npm run check && npm test` | PASS |
**언어 PASS 매트릭스**
| 서버 \ 클라이언트 | Dart.io | Dart.web | Dart.web(WSS) | Go | Kotlin | Python | TypeScript |
|---|---|---|---|---|---|---|---|
| Dart.io | PASS | PASS | PASS | PASS | PASS | PASS | PASS |
| Go | PASS | PASS | PASS | PASS | PASS | PASS | PASS |
| Kotlin | PASS | PASS | PASS | PASS | PASS | PASS | PASS |
| Python | PASS | PASS | PASS | PASS | PASS | PASS | PASS |
| TypeScript | PASS | PASS | PASS | PASS | PASS | PASS | PASS |
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?** Yes, all implementation-owned sections have been fully completed with verification outputs and architecture explanations.

View file

@ -0,0 +1,208 @@
<!-- task=m-protocol-evolution-compatibility/02+01_legacy_alias plan=0 tag=ALIAS -->
# Plan - ALIAS
## 이 파일을 읽는 구현 에이전트에게
이 plan은 full-name foundation 완료 후 legacy simple-name 수신 alias를 모든 지원 언어의 parser/listener/request/pending response 경로에 일관되게 적용한다. 구현 후 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용으로 채운다. 사용자 결정이 필요한 blocker만 review stub의 `사용자 리뷰 요청`에 기록한다.
## 배경
마일스톤은 새 송신 기본값을 full proto name으로 바꾸되 기존 simple-name peer의 수신 호환성은 보존한다. 현재 Go/Kotlin/Python/TypeScript dispatch와 pending response 비교는 exact string lookup이므로 alias 규칙을 중앙화하지 않으면 parser만 통과하고 listener/request/response가 실패하는 부분 호환 상태가 된다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 사용자 질문은 금지이며, code-review가 `USER_REVIEW.md` 작성 여부를 판단한다.
## Roadmap Targets
- Milestone: `agent-roadmap/milestones/protocol-evolution-compatibility.md`
- Task ids:
- `legacy-alias`: parser lookup, listener/request handler routing, response type matching, pending expected type 비교에 동일 alias 규칙 적용
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-test/local/rules.md`
- `agent-test/local/proto-socket-full-matrix.md`
- `agent-roadmap/milestones/protocol-evolution-compatibility.md`
- `dart/lib/src/communicator.dart`, `dart/test/communicator_test.dart`
- `go/communicator.go`, `go/test/communicator_test.go`
- `kotlin/src/main/kotlin/com/tokilabs/proto_socket/Communicator.kt`, `kotlin/src/test/kotlin/com/tokilabs/proto_socket/CommunicatorTest.kt`
- `python/proto_socket/communicator.py`, `python/test/test_communicator.py`
- `typescript/src/communicator.ts`, `typescript/test/communicator.test.ts`
- `PROTOCOL.md`, `VERSIONING.md`, `PORTING_GUIDE.md`
### 테스트 환경 규칙
`test_env=local`이다. 전체 protocol/API 호환성 변경이므로 최종 검증은 `bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all`이다. focused 중간 검증은 각 언어 unit test를 사용하되, 실행하지 못한 runtime은 명령과 차단 사유를 review stub에 기록한다.
### 테스트 커버리지 공백
- Dart에는 package-qualified receive 테스트가 있으나 canonical alias collision 검사는 없다.
- Go/Kotlin/Python/TypeScript는 simple alias receive, request handler alias, response alias, collision 초기화 실패 테스트가 부족하다.
- parser alias만 추가하면 `go/communicator.go:480-486`, `kotlin/.../Communicator.kt:421-427`, `python/.../communicator.py:519-524`, `typescript/src/communicator.ts:585-589`의 pending expected type 비교가 계속 실패한다.
### 심볼 참조
- 새 helper 후보: alias expansion/canonicalization helper per language.
- 변경 call site: parser map initialization, `AddListener`/`addListener`, `AddRequestListener`/`addRequestListener`, dispatch lookup, `_handle_response`/`handleResponse`, `parse`.
### 분할 판단
분할 정책을 평가했다. 이 plan은 `02+01_legacy_alias`이며 predecessor `01_proto_fullname_foundation``complete.log`가 필요하다. 현재 작성 시점에는 predecessor가 missing이므로 구현 시작 전 `agent-task/m-protocol-evolution-compatibility/01_proto_fullname_foundation/complete.log` 또는 archive의 `01_*` complete.log를 확인해야 한다. 테스트 matrix 확장은 `03+01,02_fullname_tests`가 맡는다.
### 범위 결정 근거
이 plan은 alias 구현과 focused unit regression만 다룬다. Proto package/generated binding 수정은 predecessor 범위이며, cross-language scenario matrix 확장과 roadmap `fullname-tests` 완료는 후속 plan 범위다.
### 빌드 등급
`cloud-G07`: string identity가 parser, dispatch, pending correlation, public typed helpers를 관통하는 다중 언어 호환성 변경이다.
## 구현 체크리스트
- [ ] 구현 시작 전 `01_proto_fullname_foundation` complete.log로 predecessor 완료를 확인한다.
- [ ] 각 언어에 canonical full name과 legacy simple name을 같은 identity group으로 다루는 helper를 추가한다.
- [ ] parser map 등록 시 full/simple alias를 모두 등록하고, simple alias가 서로 다른 full name으로 충돌하면 initialize 단계에서 실패시킨다.
- [ ] listener/request handler 등록과 상호 배타 검사, inbound dispatch, pending response expected type 비교가 같은 alias 규칙을 사용하게 한다.
- [ ] 각 언어 focused unit test로 simple legacy receive와 alias collision을 검증한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [ALIAS-1] Alias Registry Helpers
문제: current lookup은 exact key 중심이다.
```go
// go/communicator.go:497-503
parser := c.parserMap[typeName]
if parser == nil {
return nil, fmt.Errorf("protobuf parser is not registered for type %s", typeName)
}
```
해결 방법: full name에서 마지막 segment를 simple alias로 계산하는 helper와 alias group lookup을 만든다. initialize에서 parserMap을 full/simple key로 normalize하되, 같은 simple alias가 다른 parser/full name으로 매핑되면 panic/error로 실패한다.
수정 파일 및 체크리스트:
- [ ] `go/communicator.go`
- [ ] `kotlin/src/main/kotlin/com/tokilabs/proto_socket/Communicator.kt`
- [ ] `python/proto_socket/communicator.py`
- [ ] `typescript/src/communicator.ts`
- [ ] `dart/lib/src/communicator.dart`
테스트 작성: 각 언어 communicator unit에 collision test를 추가한다.
중간 검증:
```bash
go test ./...
cd kotlin && ./gradlew test
cd python && python3 -m pytest -q
cd typescript && npm run check && npm test
dart test test/communicator_test.dart
```
### [ALIAS-2] Routing And Pending Matching
문제: dispatch와 pending response expected type 비교가 exact string이면 legacy simple-name response가 실패한다.
```typescript
// typescript/src/communicator.ts:580-589
if (typeName !== pending.expectedTypeName) {
pending.reject(
new Error(
`response type mismatch for nonce ${responseNonce}: expected ${pending.expectedTypeName}, got ${typeName}`,
```
```python
# python/proto_socket/communicator.py:375-376
req_handler = self._req_handlers.get(item.type_name)
listeners = list(self._handlers.get(item.type_name, []))
```
해결 방법: incoming wire type을 canonical group으로 resolve한 뒤 parser, handler, listener, pending expected type 비교에 같은 canonical value를 사용한다. Error message는 original wire value와 expected canonical value를 모두 포함한다.
수정 파일 및 체크리스트:
- [ ] `go/communicator.go`
- [ ] `kotlin/src/main/kotlin/com/tokilabs/proto_socket/Communicator.kt`
- [ ] `python/proto_socket/communicator.py`
- [ ] `typescript/src/communicator.ts`
- [ ] `dart/lib/src/communicator.dart`
테스트 작성: request handler가 full name으로 등록되어도 incoming simple name을 처리하고, pending request expected full name이 incoming simple response를 수락하는 unit test를 추가한다.
중간 검증:
```bash
rg --sort path "typeName !=|expectedTypeName|expected_type_name|reqHandlers\\[|handlers\\[" go kotlin python typescript dart
```
### [ALIAS-3] HeartBeat Alias Consistency
문제: HeartBeat parser/listener는 각 언어에서 별도 자동 등록되며 alias에서 빠지기 쉽다.
```dart
// dart/lib/src/protobuf_client.dart:52-55
parserMap.addAll({
(HeartBeat).toString(): HeartBeat.fromBuffer,
});
```
해결 방법: HeartBeat도 `proto_socket.HeartBeat``HeartBeat` alias를 같은 registry helper로 등록한다. 자동 heartbeat listener 등록도 canonical helper를 통해 등록한다.
수정 파일 및 체크리스트:
- [ ] `dart/lib/src/protobuf_client.dart`
- [ ] `dart/lib/src/ws_protobuf_client_io.dart`
- [ ] `dart/lib/src/ws_protobuf_client_web.dart`
- [ ] `go/communicator.go`
- [ ] `kotlin/src/main/kotlin/com/tokilabs/proto_socket/Communicator.kt`
- [ ] `python/proto_socket/communicator.py`
- [ ] `typescript/src/communicator.ts`
테스트 작성: focused HeartBeat alias unit test는 있으면 추가하고, 없으면 전체 heartbeat tests가 canonical helper를 통해 통과하는지 기록한다.
중간 검증:
```bash
dart test test/communicator_test.dart test/socket_test.dart
go test ./...
cd kotlin && ./gradlew test
cd python && python3 -m pytest -q
cd typescript && npm run check && npm test
```
## 의존 관계 및 구현 순서
`02+01_legacy_alias`는 sibling `01_proto_fullname_foundation`의 complete.log가 있어야 시작한다. active 후보는 `agent-task/m-protocol-evolution-compatibility/01_proto_fullname_foundation/complete.log`, archive 후보는 `agent-task/archive/*/*/m-protocol-evolution-compatibility/01_*/complete.log`다.
## 수정 파일 요약
| 파일 | 항목 |
|---|---|
| `dart/lib/src/communicator.dart`, `dart/lib/src/protobuf_client.dart`, `dart/lib/src/ws_protobuf_client_io.dart`, `dart/lib/src/ws_protobuf_client_web.dart` | ALIAS-1, ALIAS-2, ALIAS-3 |
| `go/communicator.go` | ALIAS-1, ALIAS-2, ALIAS-3 |
| `kotlin/src/main/kotlin/com/tokilabs/proto_socket/Communicator.kt` | ALIAS-1, ALIAS-2, ALIAS-3 |
| `python/proto_socket/communicator.py` | ALIAS-1, ALIAS-2, ALIAS-3 |
| `typescript/src/communicator.ts` | ALIAS-1, ALIAS-2, ALIAS-3 |
| `*/test/*communicator*` | ALIAS-1, ALIAS-2, ALIAS-3 |
## 최종 검증
```bash
dart test test/communicator_test.dart
go test ./...
cd kotlin && ./gradlew test
cd python && python3 -m pytest -q
cd typescript && npm run check && npm test
bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all
```
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,111 @@
<!-- task=m-protocol-evolution-compatibility/03+01,02_fullname_tests plan=0 tag=TEST -->
# Code Review Reference - TEST
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation.
> Finalization is review-agent-only.
## 개요
date=2026-06-15
task=m-protocol-evolution-compatibility/03+01,02_fullname_tests, plan=0, tag=TEST
## Roadmap Targets
- Milestone: `agent-roadmap/milestones/protocol-evolution-compatibility.md`
- Task ids:
- `fullname-tests`: full name 송수신, simple name legacy 수신, request/response alias 호환, alias 충돌 감지 동일 언어 및 크로스 언어 테스트
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
리뷰 완료는 plan 스킬의 종결 순서를 따른다. full matrix record와 Dart.web/WSS coverage가 실제로 포함됐는지 확인한다.
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [TEST-1] Same-Language Identity Tests | [ ] |
| [TEST-2] Cross-Language Wire Identity Coverage | [ ] |
| [TEST-3] Full Matrix Evidence | [ ] |
## 구현 체크리스트
- [ ] 구현 시작 전 `01_proto_fullname_foundation``02+01_legacy_alias` complete.log를 확인한다.
- [ ] Dart/Go/Kotlin/Python/TypeScript 동일 언어 test에 canonical full-name 송신과 simple legacy 수신을 추가한다.
- [ ] 각 언어 test에 request/response alias 호환과 alias collision 초기화 실패를 추가한다.
- [ ] crosstest runner가 full-name wire value를 로깅/검증하고, legacy simple receive smoke를 각 server-capable 언어에 추가한다.
- [ ] full matrix를 실행하고 record path와 PASS/FAIL matrix를 review stub에 붙인다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] active files를 `.log`로 아카이브하고 PASS이면 `complete.log`를 작성한다.
- [ ] PASS이면 active task directory를 archive로 이동한다.
- [ ] PASS split 작업이면 dependency와 parent directory 상태를 확인한다.
- [ ] PASS이고 Roadmap Completion에 `fullname-tests`가 기록되도록 완료 이벤트 메타데이터를 보고한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- 동일 언어 tests가 full name, simple legacy, request/response alias, collision을 모두 다루는지 확인한다.
- crosstest가 full-name wire identity를 명시 검증하거나 최소한 record에 남기는지 확인한다.
- full matrix record에 Dart.web 및 Dart.web(WSS) coverage가 있는지 확인한다.
- 실행하지 않은 nightly/skipped 항목을 PASS로 오인하지 않았는지 확인한다.
## 검증 결과
### TEST-1 중간 검증
```bash
$ dart test test/communicator_test.dart
(output)
$ go test ./...
(output)
$ cd kotlin && ./gradlew test
(output)
$ cd python && python3 -m pytest -q
(output)
$ cd typescript && npm run check && npm test
(output)
```
### TEST-2 중간 검증
```bash
$ bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_test_by_change.sh --route auto --dry-run
(output)
```
### 최종 검증
```bash
$ bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all
(output)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**

View file

@ -0,0 +1,180 @@
<!-- task=m-protocol-evolution-compatibility/03+01,02_fullname_tests plan=0 tag=TEST -->
# Plan - TEST
## 이 파일을 읽는 구현 에이전트에게
이 plan은 identity 에픽의 검증 완성 작업이다. foundation과 alias 구현이 완료된 뒤 full name 송수신, simple legacy 수신, request/response alias 호환, alias 충돌 감지를 동일 언어와 크로스 언어 테스트에 고정한다. 구현 후 review stub의 구현 에이전트 소유 섹션을 반드시 채운다.
## 배경
full-name identity 전환은 단위 테스트만으로는 부족하다. 지원 언어 모두가 같은 wire `typeName`을 보내고, legacy simple-name을 수신 호환으로 유지하며, cross-language matrix에서 같은 시나리오를 통과해야 roadmap task를 완료할 수 있다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 사용자 질문은 금지이며, code-review가 `USER_REVIEW.md` 작성 여부를 판단한다.
## Roadmap Targets
- Milestone: `agent-roadmap/milestones/protocol-evolution-compatibility.md`
- Task ids:
- `fullname-tests`: full name 송수신, simple name legacy 수신, request/response alias 호환, alias 충돌 감지 동일 언어 및 크로스 언어 테스트
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-test/local/rules.md`
- `agent-test/local/proto-socket-full-matrix.md`
- `agent-roadmap/milestones/protocol-evolution-compatibility.md`
- `dart/test/communicator_test.dart`, `dart/crosstest/dart_go.dart`, `dart/crosstest/dart_kotlin.dart`, `dart/crosstest/dart_python.dart`, `dart/crosstest/dart_typescript.dart`, `dart/crosstest/dart_web.dart`
- `go/test/communicator_test.go`, `go/test/test_helpers_test.go`, `go/crosstest/*.go`
- `kotlin/src/test/kotlin/com/tokilabs/proto_socket/CommunicatorTest.kt`, `kotlin/src/test/kotlin/com/tokilabs/proto_socket/TestHelpers.kt`, `kotlin/crosstest/*.kt`
- `python/test/test_communicator.py`, `python/test/test_tcp.py`, `python/crosstest/*.py`
- `typescript/test/communicator.test.ts`, `typescript/test/base_client.test.ts`, `typescript/test/tcp.test.ts`, `typescript/crosstest/*.ts`
- `tools/check_proto_sync.sh`, `tools/generate_proto.sh`
### 테스트 환경 규칙
`test_env=local`이다. `proto-socket-full-matrix` profile을 읽었고, 필수 최종 검증은 `bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all`이다. 결과 record는 `agent-test/runs/YYYYMMDD-HHMMSS-proto-socket-full-matrix.md`에 남아야 하며, Dart.web/WSS client coverage가 포함되어야 한다. 실행하지 않은 항목은 PASS로 기록하지 않는다.
### 테스트 커버리지 공백
- Dart의 package-qualified receive unit은 `example.TestData` 중심이라 canonical `proto_socket.TestData`와 simple legacy 충돌을 모두 덮지 않는다.
- Go/Kotlin/Python/TypeScript unit에는 alias collision 감지 테스트가 없다.
- Cross-language runners는 send-push/request-response 성공은 보지만 full-name wire value와 simple legacy receive를 명시 검증하지 않는다.
### 심볼 참조
테스트에서 참조하는 identity symbols: `TypeNameOf`, `typeNameOf`, `type_name_of`, `TestDataSchema.typeName`, `HeartBeatSchema.typeName`, Dart `qualifiedMessageName`. rename/remove 없음.
### 분할 판단
분할 정책을 평가했다. 이 plan은 `03+01,02_fullname_tests`이며 predecessor `01_proto_fullname_foundation``02+01_legacy_alias`의 complete.log가 필요하다. 현재 작성 시점에는 둘 다 missing이다. 구현 전 active 또는 archive complete.log로 두 predecessor를 확인해야 한다.
### 범위 결정 근거
이 plan은 검증 추가와 필요한 test helper 정리만 한다. Production alias/typeName 구현 변경은 predecessor 범위이며, evolution 에픽 테스트는 다루지 않는다.
### 빌드 등급
`cloud-G07`: 전체 local matrix와 browser/WSS coverage, 다중 언어 test runner 변경을 검토해야 한다.
## 구현 체크리스트
- [ ] 구현 시작 전 `01_proto_fullname_foundation``02+01_legacy_alias` complete.log를 확인한다.
- [ ] Dart/Go/Kotlin/Python/TypeScript 동일 언어 test에 canonical full-name 송신과 simple legacy 수신을 추가한다.
- [ ] 각 언어 test에 request/response alias 호환과 alias collision 초기화 실패를 추가한다.
- [ ] crosstest runner가 full-name wire value를 로깅/검증하고, legacy simple receive smoke를 각 server-capable 언어에 추가한다.
- [ ] full matrix를 실행하고 record path와 PASS/FAIL matrix를 review stub에 붙인다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [TEST-1] Same-Language Identity Tests
문제: simple/full/collision behavior가 언어별 unit에 균일하게 없다.
```dart
// dart/test/communicator_test.dart:200-211
test('sendRequest accepts package-qualified response typeName', () async {
final communicator = _FakeCommunicator(packageQualifiedParser: true);
...
communicator.onReceivedData(
'example.TestData',
```
해결 방법: 각 언어 unit에 canonical `proto_socket.TestData` 송신 assertion, simple `TestData` legacy receive, request/response alias, collision failure를 추가한다. Dart의 `example.TestData` 테스트는 canonical package와 collision case로 갱신한다.
수정 파일 및 체크리스트:
- [ ] `dart/test/communicator_test.dart`
- [ ] `go/test/communicator_test.go`
- [ ] `kotlin/src/test/kotlin/com/tokilabs/proto_socket/CommunicatorTest.kt`
- [ ] `python/test/test_communicator.py`
- [ ] `typescript/test/communicator.test.ts`
테스트 작성: 필수. 각 behavior별 assertion을 명시한다.
중간 검증:
```bash
dart test test/communicator_test.dart
go test ./...
cd kotlin && ./gradlew test
cd python && python3 -m pytest -q
cd typescript && npm run check && npm test
```
### [TEST-2] Cross-Language Wire Identity Coverage
문제: crosstest는 성공 여부만 보고 full-name wire value를 contract로 고정하지 않는다.
```go
// go/crosstest/go_dart.go:41-49
toki.TypeNameOf(&packets.TestData{}): func(b []byte) (proto.Message, error) {
...
fmt.Printf("INFO typeName go=%s\n", toki.TypeNameOf(&packets.TestData{}))
```
해결 방법: 각 runner가 `INFO typeName <lang>=proto_socket.TestData`를 출력하거나 assert하고, server/client receive path가 full name으로 동작함을 확인한다. runner output parser가 INFO line을 무시하지 않는다면 expected value를 검증하게 한다.
수정 파일 및 체크리스트:
- [ ] `dart/crosstest/*.dart`
- [ ] `go/crosstest/*.go`
- [ ] `kotlin/crosstest/*.kt`
- [ ] `python/crosstest/*.py`
- [ ] `typescript/crosstest/*.ts`
- [ ] `agent-ops/skills/project/run-proto-socket-test-matrix/scripts/*` only if matrix parsing must record identity lines
테스트 작성: 필수. Matrix runner 변경 시 runner selfcheck/focused matrix를 실행한다.
중간 검증:
```bash
bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_test_by_change.sh --route auto --dry-run
```
### [TEST-3] Full Matrix Evidence
문제: roadmap `fullname-tests`의 검증 문구는 전체 matrix PASS를 요구한다.
해결 방법: full matrix를 한 번 실행하고 record path, Proto sync, 동일언어, 언어 PASS 매트릭스, Dart.web/WSS rows를 review stub에 붙인다. 실패하면 실패 행과 재현 명령을 기록하고 자동으로 고칠 수 있는 범위면 고친다.
수정 파일 및 체크리스트:
- [ ] `agent-test/runs/<generated>-proto-socket-full-matrix.md` 생성 여부 확인
- [ ] `CODE_REVIEW-cloud-G07.md` 검증 결과 섹션에 record path와 핵심 표 요약 붙이기
테스트 작성: 새 test 추가가 아니라 검증 evidence 수집이다.
중간 검증:
```bash
bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all
```
## 의존 관계 및 구현 순서
`03+01,02_fullname_tests`는 sibling `01_proto_fullname_foundation``02+01_legacy_alias`의 complete.log가 있어야 시작한다. active 후보는 `agent-task/m-protocol-evolution-compatibility/01_proto_fullname_foundation/complete.log``agent-task/m-protocol-evolution-compatibility/02+01_legacy_alias/complete.log`이며, archive 후보는 같은 task group 아래 `01_*`, `02+*` complete.log다.
## 수정 파일 요약
| 파일 | 항목 |
|---|---|
| `dart/test/communicator_test.dart`, `go/test/communicator_test.go`, `kotlin/src/test/kotlin/com/tokilabs/proto_socket/CommunicatorTest.kt`, `python/test/test_communicator.py`, `typescript/test/communicator.test.ts` | TEST-1 |
| `dart/crosstest/*`, `go/crosstest/*`, `kotlin/crosstest/*`, `python/crosstest/*`, `typescript/crosstest/*` | TEST-2 |
| `agent-ops/skills/project/run-proto-socket-test-matrix/scripts/*` | TEST-2, only if parser/reporting changes are required |
| `agent-test/runs/*proto-socket-full-matrix.md` | TEST-3 evidence output |
## 최종 검증
```bash
bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_test_by_change.sh --route auto --dry-run
bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all
```
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -30,6 +30,7 @@ abstract class Communicator {
_requestHandlerDic = {};
Map<int, _PendingRequest> _pendingRequests = {};
late Map<String, GeneratedMessage Function(List<int>)> _instanceGenerator;
Map<String, String> _canonicalNameMap = {};
Future<void> _outboundWrite = Future.value();
Transport? _transport;
@ -85,20 +86,59 @@ abstract class Communicator {
void initialize(
Map<String, GeneratedMessage Function(List<int>)> instanceGenerator,
{required Transport transport}) {
final shortToFull = <String, String>{};
for (final fullName in instanceGenerator.keys) {
final shortName = _shortTypeName(fullName);
if (shortToFull.containsKey(shortName) && shortToFull[shortName] != fullName) {
throw ArgumentError('Duplicate alias mapping: $shortName maps to both ${shortToFull[shortName]} and $fullName');
}
shortToFull[shortName] = fullName;
}
_canonicalNameMap = {};
for (final fullName in instanceGenerator.keys) {
_canonicalNameMap[fullName] = fullName;
final shortName = _shortTypeName(fullName);
_canonicalNameMap[shortName] = fullName;
}
_instanceGenerator = instanceGenerator;
_transport = transport;
}
String _canonicalize(String typeName) {
return _canonicalNameMap[typeName] ??
_canonicalNameMap[_shortTypeName(typeName)] ??
typeName;
}
T Function(List<int>) getGenerator<T extends GeneratedMessage>(String type) {
final generator =
_instanceGenerator[type] ?? _instanceGenerator[_shortTypeName(type)];
final canonical = _canonicalize(type);
final generator = _instanceGenerator[canonical];
if (generator == null) {
throw Exception(
'Must set protobuf packet creator before use it. Type: ${(T).toString()}');
'Must set protobuf packet creator before use it. Type: ${_qualifiedMessageNameOf<T>()}');
}
return generator as T Function(List<int>);
}
String _qualifiedMessageNameOf<T extends GeneratedMessage>() {
final shortName = T.toString();
for (final key in _instanceGenerator.keys) {
if (key == shortName || _shortTypeName(key) == shortName) {
if (key.contains('.')) {
return key;
}
}
}
for (final key in _instanceGenerator.keys) {
if (key == shortName || _shortTypeName(key) == shortName) {
return key;
}
}
return shortName;
}
String _shortTypeName(String typeName) {
final lastDot = typeName.lastIndexOf('.');
if (lastDot < 0 || lastDot == typeName.length - 1) {
@ -108,7 +148,7 @@ abstract class Communicator {
}
V? _lookupByWireType<V>(Map<String, V> map, String typeName) {
return map[typeName] ?? map[_shortTypeName(typeName)];
return map[_canonicalize(typeName)];
}
GeneratedMessage _decodeMessage(
@ -116,11 +156,11 @@ abstract class Communicator {
List<int> data, {
String? fallbackTypeName,
}) {
final generator = _instanceGenerator[typeName] ??
(fallbackTypeName == null
? null
: _instanceGenerator[fallbackTypeName]) ??
_instanceGenerator[_shortTypeName(typeName)];
final canonical = _canonicalize(typeName);
var generator = _instanceGenerator[canonical];
if (generator == null && fallbackTypeName != null) {
generator = _instanceGenerator[_canonicalize(fallbackTypeName)];
}
if (generator == null) {
throw Exception(
'Must set protobuf packet creator before use it. Type: $typeName');
@ -169,16 +209,26 @@ abstract class Communicator {
if (!isAlive) return Future.error(StateError('not connected'));
final requestNonce = nextNonce();
final completer = Completer<Res>();
final expectedResponseType = Res.toString();
final expectedResponseType = _qualifiedMessageNameOf<Res>();
_pendingRequests[requestNonce] = _PendingRequest(
expectedTypeName: expectedResponseType,
complete: (typeName, bytes) {
final canonicalExpected = _canonicalize(expectedResponseType);
final canonicalReceived = _canonicalize(typeName);
if (canonicalReceived != canonicalExpected) {
completer.completeError(
StateError(
'Response type mismatch for nonce $requestNonce: expected $canonicalExpected, got $typeName'),
StackTrace.current,
);
return;
}
final message = _decodeMessage(typeName, bytes,
fallbackTypeName: expectedResponseType);
if (message is! Res) {
completer.completeError(
StateError(
'Response type mismatch for nonce $requestNonce: expected $expectedResponseType, got $typeName'),
'Response type mismatch for nonce $requestNonce: expected $canonicalExpected, got $typeName'),
StackTrace.current,
);
return;
@ -216,18 +266,19 @@ abstract class Communicator {
/// ```
void addRequestListener<Req extends GeneratedMessage,
Res extends GeneratedMessage>(Future<Res> Function(Req) handler) {
final reqType = Req.toString();
if (_handlerDic.containsKey(reqType)) {
final reqType = _qualifiedMessageNameOf<Req>();
final canonicalReq = _canonicalize(reqType);
if (_handlerDic.containsKey(canonicalReq)) {
throw StateError(
'Type $reqType is already registered with addListener and cannot also use addRequestListener.');
}
_requestHandlerDic[reqType] =
_requestHandlerDic[canonicalReq] =
(String typeName, List<int> bytes, int requestNonce) async {
final message =
_decodeMessage(typeName, bytes, fallbackTypeName: reqType);
if (message is! Req) {
throw StateError(
'Request type mismatch for nonce $requestNonce: expected $reqType, got $typeName');
'Request type mismatch for nonce $requestNonce: expected ${_canonicalize(reqType)}, got $typeName');
}
final req = message;
final res = await handler(req);
@ -442,42 +493,44 @@ abstract class Communicator {
pending.complete(item.typeName, item.data);
return;
}
final requestHandler =
_lookupByWireType(_requestHandlerDic, item.typeName);
final canonical = _canonicalize(item.typeName);
final requestHandler = _requestHandlerDic[canonical];
if (requestHandler != null) {
await requestHandler(item.typeName, item.data, item.incomingNonce);
return;
}
final handler = _lookupByWireType(_handlerDic, item.typeName);
final handler = _handlerDic[canonical];
if (handler != null) {
handler.onMessage(item.typeName, item.data);
}
}
void addListener<T extends GeneratedMessage>(void Function(T) listener) {
var type = T.toString();
if (_requestHandlerDic.containsKey(type)) {
var type = _qualifiedMessageNameOf<T>();
var canonical = _canonicalize(type);
if (_requestHandlerDic.containsKey(canonical)) {
throw StateError(
'Type $type is already registered with addRequestListener and cannot also use addListener.');
}
if (!_handlerDic.containsKey(type)) {
_handlerDic[type] = DataHandler<T>((typeName, data) {
if (!_handlerDic.containsKey(canonical)) {
_handlerDic[canonical] = DataHandler<T>((typeName, data) {
final message = _decodeMessage(typeName, data, fallbackTypeName: type);
if (message is! T) {
throw StateError(
'Message type mismatch: expected $type, got $typeName');
'Message type mismatch: expected $canonical, got $typeName');
}
return message;
});
}
var handler = _handlerDic[type] as DataHandler<T>;
var handler = _handlerDic[canonical] as DataHandler<T>;
handler.addListener(listener);
}
void removeListener<T extends GeneratedMessage>(void Function(T) listener) {
var type = T.toString();
if (_handlerDic.containsKey(type)) {
var handler = _handlerDic[type] as DataHandler<T>;
var type = _qualifiedMessageNameOf<T>();
var canonical = _canonicalize(type);
if (_handlerDic.containsKey(canonical)) {
var handler = _handlerDic[canonical] as DataHandler<T>;
handler.removeListener(listener);
}
}

View file

@ -42,6 +42,7 @@ class PacketBase extends $pb.GeneratedMessage {
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'PacketBase',
package: const $pb.PackageName(_omitMessageNames ? '' : 'proto_socket'),
createEmptyInstance: create)
..aOS(1, _omitFieldNames ? '' : 'typeName', protoName: 'typeName')
..aI(2, _omitFieldNames ? '' : 'nonce')
@ -119,6 +120,7 @@ class HeartBeat extends $pb.GeneratedMessage {
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'HeartBeat',
package: const $pb.PackageName(_omitMessageNames ? '' : 'proto_socket'),
createEmptyInstance: create)
..hasRequiredFields = false;
@ -163,6 +165,7 @@ class TestData extends $pb.GeneratedMessage {
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'TestData',
package: const $pb.PackageName(_omitMessageNames ? '' : 'proto_socket'),
createEmptyInstance: create)
..aI(1, _omitFieldNames ? '' : 'index')
..aOS(2, _omitFieldNames ? '' : 'message')

View file

@ -50,7 +50,7 @@ abstract class ProtobufClient extends BaseClient<ProtobufClient> {
_transport = _TcpSocketTransport(_socket, _headerSize);
isAlive = true;
parserMap.addAll({
(HeartBeat).toString(): HeartBeat.fromBuffer,
HeartBeat.getDefault().info_.qualifiedMessageName: HeartBeat.fromBuffer,
});
super.initialize(parserMap, transport: _transport);
// gateway-backed coordinator로 . start()

View file

@ -45,7 +45,7 @@ abstract class WsProtobufClient extends BaseClient<WsProtobufClient> {
initHeartbeat(heartbeatIntervalTime, heartbeatWaitTime);
_transport = _WebSocketTransport(_ws);
isAlive = true;
parserMap.addAll({(HeartBeat).toString(): HeartBeat.fromBuffer});
parserMap.addAll({HeartBeat.getDefault().info_.qualifiedMessageName: HeartBeat.fromBuffer});
super.initialize(parserMap, transport: _transport);
// TCP gateway-backed receive coordinator를 . start()
// _ready completer를 spawn drop되지 .

View file

@ -68,7 +68,7 @@ abstract class WsProtobufClient extends BaseClient<WsProtobufClient> {
initHeartbeat(heartbeatIntervalTime, heartbeatWaitTime);
_transport = _WebSocketTransport(_ws);
isAlive = true;
parserMap.addAll({(HeartBeat).toString(): HeartBeat.fromBuffer});
parserMap.addAll({HeartBeat.getDefault().info_.qualifiedMessageName: HeartBeat.fromBuffer});
super.initialize(parserMap, transport: _transport);
// web에는 worker isolate가 IsolateInboundGateway는 SyncInboundGateway
// fallback으로 in-process decode하되 seq-ordered receive coordinator

View file

@ -5,6 +5,8 @@ import 'dart:typed_data';
import 'package:test/test.dart';
import 'package:proto_socket/proto_socket.dart';
class _CollisionCommunicator extends Communicator {}
class _FakeCommunicator extends Communicator {
final _FakeTransport transport = _FakeTransport();
@ -310,7 +312,7 @@ void main() {
final error = StateError('write failed');
communicator.transport.error = error;
await expectLater(
communicator.queuePacket(PacketBase()..typeName = 'TestData'),
communicator.queuePacket(PacketBase()..typeName = TestData.getDefault().info_.qualifiedMessageName),
throwsA(same(error)),
);
});
@ -821,6 +823,50 @@ void main() {
expect(gateway.queuedCount, 0);
}
});
test('Alias collision during initialization triggers ArgumentError', () {
final transport = _FakeTransport();
expect(
() => _CollisionCommunicator().initialize({
'a.b.TestData': TestData.fromBuffer,
'x.y.TestData': TestData.fromBuffer,
}, transport: transport),
throwsA(isA<ArgumentError>()),
);
});
test('Legacy simple-name alias routing and pending matching', () async {
final communicator = _FakeCommunicator();
// 1. Test Listener routing with legacy simple name
var listenerCalled = false;
var receivedMsg;
communicator.addListener<TestData>((m) {
listenerCalled = true;
receivedMsg = m;
});
final testMsgBytes = (TestData()..index = 42..message = 'test').writeToBuffer();
communicator.onReceivedData('TestData', testMsgBytes, incomingNonce: 1);
await Future<void>.delayed(Duration.zero);
expect(listenerCalled, isTrue);
expect(receivedMsg.index, 42);
// 2. Test Pending request matching with legacy simple name
final future = communicator.sendRequest<TestData, TestData>(
TestData()..index = 1,
);
await Future<void>.delayed(Duration.zero);
final requestNonce = communicator.transport.sentPackets.last.nonce;
communicator.onReceivedData('TestData', testMsgBytes, responseNonce: requestNonce);
final response = await future;
expect(response.index, 42);
communicator.closeForTest();
});
});
}

View file

@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"reflect"
"strings"
"sync"
"sync/atomic"
"time"
@ -61,6 +62,7 @@ type Communicator struct {
transport Transport
writeErrHandler func(error)
frameErrHandler func(error)
canonicalNameMap map[string]string
}
func TypeNameOf(m proto.Message) string {
@ -73,16 +75,36 @@ func NewCommunicator(transport Transport, parserMap ParserMap) *Communicator {
return c
}
func getShortTypeName(typeName string) string {
idx := strings.LastIndex(typeName, ".")
if idx < 0 || idx == len(typeName)-1 {
return typeName
}
return typeName[idx+1:]
}
func (c *Communicator) canonicalizeLocked(typeName string) string {
if c.canonicalNameMap == nil {
return typeName
}
if name, ok := c.canonicalNameMap[typeName]; ok {
return name
}
short := getShortTypeName(typeName)
if name, ok := c.canonicalNameMap[short]; ok {
return name
}
return typeName
}
func (c *Communicator) canonicalize(typeName string) string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.canonicalizeLocked(typeName)
}
func (c *Communicator) Initialize(transport Transport, parserMap ParserMap) {
c.transport = transport
c.parserMap = make(ParserMap, len(parserMap)+1)
for k, v := range parserMap {
c.parserMap[k] = v
}
c.parserMap[TypeNameOf(&packets.HeartBeat{})] = func(b []byte) (proto.Message, error) {
m := &packets.HeartBeat{}
return m, proto.Unmarshal(b, m)
}
c.handlers = make(map[string][]func(proto.Message))
c.reqHandlers = make(map[string]func(proto.Message, int32))
c.pendingRequests = make(map[int32]*pendingRequest)
@ -91,6 +113,43 @@ func (c *Communicator) Initialize(transport Transport, parserMap ParserMap) {
c.closed = make(chan struct{})
c.receiveDone = make(chan struct{})
c.isAlive.Store(true)
c.mu.Lock()
c.canonicalNameMap = make(map[string]string)
shortToFull := make(map[string]string)
hbName := TypeNameOf(&packets.HeartBeat{})
tempParsers := make(ParserMap)
for k, v := range parserMap {
tempParsers[k] = v
}
tempParsers[hbName] = func(b []byte) (proto.Message, error) {
m := &packets.HeartBeat{}
return m, proto.Unmarshal(b, m)
}
for fullName := range tempParsers {
shortName := getShortTypeName(fullName)
if existing, ok := shortToFull[shortName]; ok && existing != fullName {
c.mu.Unlock()
panic(fmt.Sprintf("Duplicate alias mapping: %s maps to both %s and %s", shortName, existing, fullName))
}
shortToFull[shortName] = fullName
}
for fullName := range tempParsers {
c.canonicalNameMap[fullName] = fullName
shortName := getShortTypeName(fullName)
c.canonicalNameMap[shortName] = fullName
}
c.parserMap = make(ParserMap)
for k, v := range tempParsers {
c.parserMap[c.canonicalizeLocked(k)] = v
}
c.mu.Unlock()
go c.writeLoop()
go c.receiveLoop()
}
@ -303,29 +362,32 @@ func (c *Communicator) AddListener(typeName string, fn func(proto.Message)) {
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.reqHandlers[typeName]; ok {
canonical := c.canonicalizeLocked(typeName)
if _, ok := c.reqHandlers[canonical]; ok {
panic(fmt.Sprintf("type %s is already registered with AddRequestListener", typeName))
}
c.handlers[typeName] = append(c.handlers[typeName], fn)
c.handlers[canonical] = append(c.handlers[canonical], fn)
}
func (c *Communicator) RemoveListeners(typeName string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.handlers, typeName)
canonical := c.canonicalizeLocked(typeName)
delete(c.handlers, canonical)
}
func (c *Communicator) AddRequestListener(typeName string, fn func(proto.Message, int32)) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.handlers[typeName]) > 0 {
canonical := c.canonicalizeLocked(typeName)
if len(c.handlers[canonical]) > 0 {
panic(fmt.Sprintf("type %s is already registered with AddListener", typeName))
}
if _, ok := c.reqHandlers[typeName]; ok {
if _, ok := c.reqHandlers[canonical]; ok {
panic(fmt.Sprintf("type %s is already registered with AddRequestListener", typeName))
}
c.reqHandlers[typeName] = fn
c.reqHandlers[canonical] = fn
}
func (c *Communicator) OnReceivedData(typeName string, data []byte, incomingNonce, responseNonce int32) {
@ -452,8 +514,9 @@ func (c *Communicator) receiveLoop() {
func (c *Communicator) dispatchInbound(item inboundItem) {
c.mu.RLock()
reqHandler := c.reqHandlers[item.typeName]
listeners := append([]func(proto.Message){}, c.handlers[item.typeName]...)
canonical := c.canonicalizeLocked(item.typeName)
reqHandler := c.reqHandlers[canonical]
listeners := append([]func(proto.Message){}, c.handlers[canonical]...)
c.mu.RUnlock()
if reqHandler != nil {
@ -482,8 +545,10 @@ func (c *Communicator) handleResponse(typeName string, data []byte, responseNonc
if pending == nil {
return
}
if typeName != pending.expectedTypeName {
pending.errCh <- fmt.Errorf("response type mismatch for nonce %d: expected %s, got %s", responseNonce, pending.expectedTypeName, typeName)
canonicalExpected := c.canonicalize(pending.expectedTypeName)
canonicalReceived := c.canonicalize(typeName)
if canonicalReceived != canonicalExpected {
pending.errCh <- fmt.Errorf("response type mismatch for nonce %d: expected %s, got %s", responseNonce, canonicalExpected, typeName)
return
}
msg, err := c.parse(typeName, data)
@ -496,7 +561,8 @@ func (c *Communicator) handleResponse(typeName string, data []byte, responseNonc
func (c *Communicator) parse(typeName string, data []byte) (proto.Message, error) {
c.mu.RLock()
parser := c.parserMap[typeName]
canonical := c.canonicalizeLocked(typeName)
parser := c.parserMap[canonical]
c.mu.RUnlock()
if parser == nil {
return nil, fmt.Errorf("protobuf parser is not registered for type %s", typeName)

View file

@ -181,7 +181,7 @@ var File_packets_message_common_proto protoreflect.FileDescriptor
const file_packets_message_common_proto_rawDesc = "" +
"\n" +
"\x1cpackets/message_common.proto\"x\n" +
"\x1cpackets/message_common.proto\x12\fproto_socket\"x\n" +
"\n" +
"PacketBase\x12\x1a\n" +
"\btypeName\x18\x01 \x01(\tR\btypeName\x12\x14\n" +
@ -207,9 +207,9 @@ func file_packets_message_common_proto_rawDescGZIP() []byte {
var file_packets_message_common_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
var file_packets_message_common_proto_goTypes = []any{
(*PacketBase)(nil), // 0: PacketBase
(*HeartBeat)(nil), // 1: HeartBeat
(*TestData)(nil), // 2: TestData
(*PacketBase)(nil), // 0: proto_socket.PacketBase
(*HeartBeat)(nil), // 1: proto_socket.HeartBeat
(*TestData)(nil), // 2: proto_socket.TestData
}
var file_packets_message_common_proto_depIdxs = []int32{
0, // [0:0] is the sub-list for method output_type

View file

@ -1,5 +1,7 @@
syntax = "proto3";
package proto_socket;
option go_package = "git.toki-labs.com/toki/proto-socket/go/packets";
message PacketBase {

View file

@ -690,3 +690,89 @@ func TestTcpReadLoopGatewayParseErrorDisconnects(t *testing.T) {
t.Fatalf("expected %s disconnect via gateway error path, got %q", toki.DisconnectReasonTCPPacketParse, info.Reason)
}
}
func TestAliasCollisionOnInit(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected panic on alias collision")
}
}()
badParserMap := toki.ParserMap{
"a.b.TestData": func(b []byte) (proto.Message, error) {
return &packets.TestData{}, nil
},
"x.y.TestData": func(b []byte) (proto.Message, error) {
return &packets.TestData{}, nil
},
}
transport := &fakeTransport{}
_ = toki.NewCommunicator(transport, badParserMap)
}
func TestLegacyAliasRouting(t *testing.T) {
transport := &fakeTransport{}
communicator := toki.NewCommunicator(transport, testParserMap())
defer communicator.Close()
// 1. Test Listener routing with legacy simple name
var listenerCalled bool
var receivedMsg proto.Message
toki.AddListenerTyped[*packets.TestData](communicator, func(m *packets.TestData) {
listenerCalled = true
receivedMsg = m
})
testMsgBytes, _ := proto.Marshal(&packets.TestData{Index: 42, Message: "test"})
communicator.OnReceivedData("TestData", testMsgBytes, 1, 0)
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if listenerCalled {
break
}
time.Sleep(time.Millisecond)
}
if !listenerCalled {
t.Fatal("listener was not called with legacy simple name")
}
if receivedMsg.(*packets.TestData).Index != 42 {
t.Fatalf("expected Index 42, got %d", receivedMsg.(*packets.TestData).Index)
}
// 2. Test Pending request matching with legacy simple name
errCh := make(chan error, 1)
var responseMsg proto.Message
go func() {
res, err := toki.SendRequestTyped[*packets.TestData, *packets.TestData](
communicator,
&packets.TestData{Index: 1, Message: "req"},
time.Second,
)
responseMsg = res
errCh <- err
}()
var requestNonce int32
for deadline = time.Now().Add(time.Second); time.Now().Before(deadline); {
sent := transport.sent()
if len(sent) == 1 {
requestNonce = sent[0].GetNonce()
break
}
time.Sleep(time.Millisecond)
}
if requestNonce == 0 {
t.Fatal("request packet was not sent")
}
communicator.OnReceivedData("TestData", testMsgBytes, 0, requestNonce)
err := <-errCh
if err != nil {
t.Fatalf("expected successful response matching, got error: %v", err)
}
if responseMsg.(*packets.TestData).Index != 42 {
t.Fatalf("expected response Index 42, got %d", responseMsg.(*packets.TestData).Index)
}
}

View file

@ -18,8 +18,8 @@ import (
)
func TestTypeNameMatchesDartConvention(t *testing.T) {
if got := toki.TypeNameOf(&packets.TestData{}); got != "TestData" {
t.Fatalf("type name = %q, want TestData", got)
if got := toki.TypeNameOf(&packets.TestData{}); got != "proto_socket.TestData" {
t.Fatalf("type name = %q, want proto_socket.TestData", got)
}
}

View file

@ -90,14 +90,51 @@ class Communicator(
private var writeErrorHandler: ((Throwable) -> Unit)? = null
private var frameErrorHandler: ((Throwable) -> Unit)? = null
private var gateway: InboundGateway? = null
private val canonicalNameMap = ConcurrentHashMap<String, String>()
init {
initialize(parserMap)
}
private fun getShortTypeName(typeName: String): String {
val lastDot = typeName.lastIndexOf('.')
return if (lastDot < 0 || lastDot == typeName.length - 1) {
typeName
} else {
typeName.substring(lastDot + 1)
}
}
private fun canonicalize(typeName: String): String {
return canonicalNameMap[typeName]
?: canonicalNameMap[getShortTypeName(typeName)]
?: typeName
}
private fun initialize(parserMap: ParserMap) {
this.parserMap.putAll(parserMap)
this.parserMap[typeNameOf<HeartBeat>()] = { HeartBeat.parseFrom(it) }
val shortToFull = mutableMapOf<String, String>()
val tempParsers = parserMap.toMutableMap()
tempParsers[typeNameOf<HeartBeat>()] = { HeartBeat.parseFrom(it) }
for (fullName in tempParsers.keys) {
val shortName = getShortTypeName(fullName)
val existing = shortToFull[shortName]
if (existing != null && existing != fullName) {
throw IllegalArgumentException("Duplicate alias mapping: $shortName maps to both $existing and $fullName")
}
shortToFull[shortName] = fullName
}
for (fullName in tempParsers.keys) {
canonicalNameMap[fullName] = fullName
val shortName = getShortTypeName(fullName)
canonicalNameMap[shortName] = fullName
}
for ((k, v) in tempParsers) {
this.parserMap[canonicalize(k)] = v
}
isAlive.set(true)
isClosing.set(false)
scope.launch { writeLoop() }
@ -310,26 +347,31 @@ class Communicator(
fun addListener(typeName: String, fn: (MessageLite) -> Unit) {
rwLock.write {
check(!reqHandlers.containsKey(typeName)) {
val canonical = canonicalize(typeName)
check(!reqHandlers.containsKey(canonical)) {
"type $typeName is already registered with addRequestListener"
}
handlers.getOrPut(typeName) { mutableListOf() }.add(fn)
handlers.getOrPut(canonical) { mutableListOf() }.add(fn)
}
}
fun removeListeners(typeName: String) {
rwLock.write { handlers.remove(typeName) }
rwLock.write {
val canonical = canonicalize(typeName)
handlers.remove(canonical)
}
}
fun addRequestListener(typeName: String, fn: RequestHandler) {
rwLock.write {
check(handlers[typeName].isNullOrEmpty()) {
val canonical = canonicalize(typeName)
check(handlers[canonical].isNullOrEmpty()) {
"type $typeName is already registered with addListener"
}
check(!reqHandlers.containsKey(typeName)) {
check(!reqHandlers.containsKey(canonical)) {
"type $typeName is already registered with addRequestListener"
}
reqHandlers[typeName] = fn
reqHandlers[canonical] = fn
}
}
@ -397,9 +439,10 @@ class Communicator(
val reqHandler: RequestHandler?
val listeners: List<(MessageLite) -> Unit>
val canonical = canonicalize(item.typeName)
rwLock.read {
reqHandler = reqHandlers[item.typeName]
listeners = handlers[item.typeName]?.toList().orEmpty()
reqHandler = reqHandlers[canonical]
listeners = handlers[canonical]?.toList().orEmpty()
}
if (reqHandler != null) {
@ -419,11 +462,13 @@ class Communicator(
responseNonce: Int,
) {
val pending = removePending(responseNonce) ?: return
if (typeName != pending.expectedTypeName) {
val canonicalExpected = canonicalize(pending.expectedTypeName)
val canonicalReceived = canonicalize(typeName)
if (canonicalReceived != canonicalExpected) {
pending.errCh.trySend(
IllegalStateException(
"response type mismatch for nonce $responseNonce: " +
"expected ${pending.expectedTypeName}, got $typeName",
"expected $canonicalExpected, got $typeName",
),
)
return
@ -434,7 +479,8 @@ class Communicator(
}
internal fun parse(typeName: String, data: ByteArray): MessageLite {
val parser = parserMap[typeName]
val canonical = canonicalize(typeName)
val parser = parserMap[canonical]
?: throw IllegalStateException("protobuf parser is not registered for type $typeName")
return parser(data)
}

View file

@ -1,5 +1,7 @@
syntax = "proto3";
package proto_socket;
option java_package = "com.tokilabs.proto_socket.packets";
option java_outer_classname = "MessageCommonProto";
option java_multiple_files = true;

View file

@ -681,5 +681,61 @@ class CommunicatorTest {
delay(50)
assertTrue(dispatched.isEmpty(), "submit after close should be dropped")
gateway.close()
}
@Test
fun testAliasCollisionOnInit() = runTest {
val badParserMap: ParserMap = mapOf(
"a.b.TestData" to { TestData.parseFrom(it) },
"x.y.TestData" to { TestData.parseFrom(it) },
)
val transport = FakeTransport()
assertFailsWith<IllegalArgumentException> {
Communicator(transport, badParserMap, this)
}
}
@Test
fun testLegacyAliasRouting() = runTest {
val transport = FakeTransport()
val communicator = Communicator(transport, testParserMap(), this)
// 1. Test Listener routing with legacy simple name
var listenerCalled = false
var receivedMsg: com.google.protobuf.MessageLite? = null
addListenerTyped<TestData>(communicator) { msg ->
listenerCalled = true
receivedMsg = msg
}
val testMsgBytes = TestData.newBuilder().setIndex(42).setMessage("test").build().toByteArray()
communicator.onReceivedData("TestData", testMsgBytes, incomingNonce = 1)
var elapsed = 0
while (!listenerCalled && elapsed < 1000) {
delay(5)
elapsed += 5
}
assertTrue(listenerCalled, "listener should be called with legacy simple name")
assertEquals(42, (receivedMsg as TestData).index)
// 2. Test Pending request matching with legacy simple name
val result = async {
sendRequestTyped<TestData, TestData>(
communicator,
TestData.newBuilder().setIndex(1).setMessage("req").build(),
timeoutMs = 1_000,
)
}
while (transport.packets.isEmpty()) delay(1)
val requestNonce = transport.packets.single().nonce
communicator.onReceivedData("TestData", testMsgBytes, responseNonce = requestNonce)
val response = result.await()
assertEquals(42, response.index)
communicator.close()
}
}

View file

@ -1,5 +1,7 @@
syntax = "proto3";
package proto_socket;
message PacketBase {
string typeName = 1;
int32 nonce = 2;

View file

@ -60,6 +60,13 @@ class Transport(Protocol):
async def close(self) -> None: ...
def _short_type_name(type_name: str) -> str:
last_dot = type_name.rfind(".")
if last_dot < 0 or last_dot == len(type_name) - 1:
return type_name
return type_name[last_dot + 1 :]
class Communicator:
def __init__(self) -> None:
self._nonce = 0
@ -85,7 +92,17 @@ class Communicator:
self._inbound_semaphore: asyncio.Semaphore | None = None
self._parser_cls_map: dict[str, type[Message]] = {}
self._gateway_tasks: set[asyncio.Task[Any]] = set()
self._canonical_name_map: dict[str, str] = {}
def _canonicalize(self, type_name: str) -> str:
if not hasattr(self, "_canonical_name_map") or not self._canonical_name_map:
return type_name
if type_name in self._canonical_name_map:
return self._canonical_name_map[type_name]
short = _short_type_name(type_name)
if short in self._canonical_name_map:
return self._canonical_name_map[short]
return type_name
def initialize(
self,
@ -102,8 +119,29 @@ class Communicator:
sys.path.insert(0, packets_dir)
self._transport = transport
self._parser_map = dict(parser_map)
self._parser_map[type_name_of(HeartBeat)] = HeartBeat.FromString
# Build canonical_name_map and check collision
self._canonical_name_map = {}
short_to_full = {}
temp_parsers = dict(parser_map)
temp_parsers[type_name_of(HeartBeat)] = HeartBeat.FromString
for full_name in temp_parsers:
short_name = _short_type_name(full_name)
existing = short_to_full.get(short_name)
if existing is not None and existing != full_name:
raise ValueError(f"Duplicate alias mapping: {short_name} maps to both {existing} and {full_name}")
short_to_full[short_name] = full_name
for full_name in temp_parsers:
self._canonical_name_map[full_name] = full_name
short_name = _short_type_name(full_name)
self._canonical_name_map[short_name] = full_name
self._parser_map = {}
for k, v in temp_parsers.items():
self._parser_map[self._canonicalize(k)] = v
self._parser_cls_map = {}
for k, parser in self._parser_map.items():
@ -300,19 +338,22 @@ class Communicator:
self._pending_requests.pop(request_nonce, None)
def add_listener(self, type_name: str, fn: Callable[[Message], Any]) -> None:
if type_name in self._req_handlers:
canonical = self._canonicalize(type_name)
if canonical in self._req_handlers:
raise ValueError(f"type {type_name} is already registered with add_request_listener")
self._handlers.setdefault(type_name, []).append(fn)
self._handlers.setdefault(canonical, []).append(fn)
def remove_listeners(self, type_name: str) -> None:
self._handlers.pop(type_name, None)
canonical = self._canonicalize(type_name)
self._handlers.pop(canonical, None)
def add_request_listener(self, type_name: str, fn: RequestHandler) -> None:
if self._handlers.get(type_name):
canonical = self._canonicalize(type_name)
if self._handlers.get(canonical):
raise ValueError(f"type {type_name} is already registered with add_listener")
if type_name in self._req_handlers:
if canonical in self._req_handlers:
raise ValueError(f"type {type_name} is already registered with add_request_listener")
self._req_handlers[type_name] = fn
self._req_handlers[canonical] = fn
def on_received_data(
self,
@ -372,8 +413,9 @@ class Communicator:
self._handle_response(item.type_name, item.data, item.response_nonce)
return
req_handler = self._req_handlers.get(item.type_name)
listeners = list(self._handlers.get(item.type_name, []))
canonical = self._canonicalize(item.type_name)
req_handler = self._req_handlers.get(canonical)
listeners = list(self._handlers.get(canonical, []))
if item.parsed_message is not None:
message = item.parsed_message
@ -516,11 +558,13 @@ class Communicator:
if item is None:
return
expected_type_name, pending = item
if type_name != expected_type_name:
canonical_expected = self._canonicalize(expected_type_name)
canonical_received = self._canonicalize(type_name)
if canonical_received != canonical_expected:
pending.set_exception(
ValueError(
f"response type mismatch for nonce {response_nonce}: "
f"expected {expected_type_name}, got {type_name}"
f"expected {canonical_expected}, got {type_name}"
)
)
return
@ -530,7 +574,8 @@ class Communicator:
pending.set_exception(exc)
def _parse(self, type_name: str, data: bytes) -> Message:
parser = self._parser_map.get(type_name)
canonical = self._canonicalize(type_name)
parser = self._parser_map.get(canonical)
if parser is None:
raise ValueError(f"protobuf parser is not registered for type {type_name}")
return parser(data)
@ -540,7 +585,7 @@ def type_name_of(message_or_class: Message | type[Message]) -> str:
descriptor = getattr(message_or_class, "DESCRIPTOR", None)
if descriptor is None:
descriptor = message_or_class.__class__.DESCRIPTOR
return descriptor.name
return descriptor.full_name
def _accepts_nonce(handler: RequestHandler) -> bool:

View file

@ -1,5 +1,7 @@
syntax = "proto3";
package proto_socket;
message PacketBase {
string typeName = 1;
int32 nonce = 2;

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: message_common.proto
# source: proto_socket/packets/message_common.proto
# Protobuf Python Version: 5.29.3
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
@ -15,7 +15,7 @@ _runtime_version.ValidateProtobufRuntimeVersion(
29,
3,
'',
'message_common.proto'
'proto_socket/packets/message_common.proto'
)
# @@protoc_insertion_point(imports)
@ -24,17 +24,17 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x14message_common.proto\"R\n\nPacketBase\x12\x10\n\x08typeName\x18\x01 \x01(\t\x12\r\n\x05nonce\x18\x02 \x01(\x05\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12\x15\n\rresponseNonce\x18\x04 \x01(\x05\"\x0b\n\tHeartBeat\"*\n\x08TestData\x12\r\n\x05index\x18\x01 \x01(\x05\x12\x0f\n\x07message\x18\x02 \x01(\tb\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n)proto_socket/packets/message_common.proto\x12\x0cproto_socket\"R\n\nPacketBase\x12\x10\n\x08typeName\x18\x01 \x01(\t\x12\r\n\x05nonce\x18\x02 \x01(\x05\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12\x15\n\rresponseNonce\x18\x04 \x01(\x05\"\x0b\n\tHeartBeat\"*\n\x08TestData\x12\r\n\x05index\x18\x01 \x01(\x05\x12\x0f\n\x07message\x18\x02 \x01(\tb\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'message_common_pb2', _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'proto_socket.packets.message_common_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_PACKETBASE']._serialized_start=24
_globals['_PACKETBASE']._serialized_end=106
_globals['_HEARTBEAT']._serialized_start=108
_globals['_HEARTBEAT']._serialized_end=119
_globals['_TESTDATA']._serialized_start=121
_globals['_TESTDATA']._serialized_end=163
_globals['_PACKETBASE']._serialized_start=59
_globals['_PACKETBASE']._serialized_end=141
_globals['_HEARTBEAT']._serialized_start=143
_globals['_HEARTBEAT']._serialized_end=154
_globals['_TESTDATA']._serialized_start=156
_globals['_TESTDATA']._serialized_end=198
# @@protoc_insertion_point(module_scope)

View file

@ -48,7 +48,7 @@ async def test_send_and_receive():
await communicator.send(ProtoTestData(index=7, message="hello"))
assert len(transport.packets) == 1
packet = transport.packets[0]
assert packet.typeName == "TestData"
assert packet.typeName == "proto_socket.TestData"
assert packet.nonce == 1
communicator.on_received_data(packet.typeName, packet.data, packet.nonce, 0)
@ -251,21 +251,21 @@ async def test_inbound_backpressure_on_heavy_load():
await handler_promise.wait()
return ProtoTestData(index=100)
communicator.add_request_listener("TestData", blocking_handler)
communicator.add_request_listener(type_name_of(ProtoTestData), blocking_handler)
msg = ProtoTestData(index=1, message="heavy")
data = msg.SerializeToString()
# 1번째 호출: 핸들러가 handler_promise에 의해 블로킹됨
await communicator.enqueue_inbound("TestData", data, 1, 0)
await communicator.enqueue_inbound(type_name_of(ProtoTestData), data, 1, 0)
# 2번째부터 65번째(총 64개)를 enqueue: 첫번째 아이템은 디스패치 중이므로 큐에는 현재 0개 있음
# 2 ~ 65번째(총 64개) 추가 시 큐가 꽉 차게 됨
for i in range(2, 66):
await communicator.enqueue_inbound("TestData", data, i, 0)
await communicator.enqueue_inbound(type_name_of(ProtoTestData), data, i, 0)
# 66번째 item 추가 시도: 비동기로 대기(블로킹)해야 함
enqueue_task = asyncio.create_task(communicator.enqueue_inbound("TestData", data, 66, 0))
enqueue_task = asyncio.create_task(communicator.enqueue_inbound(type_name_of(ProtoTestData), data, 66, 0))
await asyncio.sleep(0.02)
# 큐가 비워지지 않았으므로 enqueue_task는 unblock되지 않아야 함 (Pending)
@ -595,4 +595,48 @@ async def test_queue_full_ordering_and_close_cleanup():
assert task67.done()
@pytest.mark.asyncio
async def test_alias_collision_on_init():
bad_parser_map = {
"a.b.TestData": ProtoTestData.FromString,
"x.y.TestData": ProtoTestData.FromString,
}
transport = FakeTransport()
communicator = Communicator()
with pytest.raises(ValueError):
communicator.initialize(transport, bad_parser_map)
@pytest.mark.asyncio
async def test_legacy_alias_routing():
transport = FakeTransport()
communicator = Communicator()
communicator.initialize(transport, parser_map())
# 1. Test Listener routing with legacy simple name
received = asyncio.get_running_loop().create_future()
communicator.add_listener(type_name_of(ProtoTestData), received.set_result)
test_msg = ProtoTestData(index=42, message="test")
communicator.on_received_data("TestData", test_msg.SerializeToString(), 1, 0)
result = await asyncio.wait_for(received, 1.0)
assert result.index == 42
# 2. Test Pending request matching with legacy simple name
task = asyncio.create_task(
communicator.send_request(ProtoTestData(index=1, message="req"), ProtoTestData, timeout=1.0)
)
for _ in range(20):
if transport.packets:
break
await asyncio.sleep(0.01)
assert len(transport.packets) == 1
request = transport.packets[0]
communicator.on_received_data("TestData", test_msg.SerializeToString(), 0, request.nonce)
result2 = await task
assert result2.index == 42
await communicator.close()

View file

@ -5,6 +5,7 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
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"
python_proto="$repo_root/python/proto_socket/packets/message_common.proto"
if [[ ! -f "$canonical_proto" ]]; then
echo "Missing canonical proto: $canonical_proto" >&2
@ -21,6 +22,11 @@ if [[ -d "$repo_root/kotlin" && ! -f "$kotlin_proto" ]]; then
exit 1
fi
if [[ -d "$repo_root/python" && ! -f "$python_proto" ]]; then
echo "Missing Python proto copy: $python_proto" >&2
exit 1
fi
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
@ -40,6 +46,9 @@ normalize_proto "$go_proto" >"$tmp_dir/go.proto"
if [[ -f "$kotlin_proto" ]]; then
normalize_proto "$kotlin_proto" >"$tmp_dir/kotlin.proto"
fi
if [[ -f "$python_proto" ]]; then
normalize_proto "$python_proto" >"$tmp_dir/python.proto"
fi
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
@ -53,4 +62,10 @@ if [[ -f "$tmp_dir/kotlin.proto" ]] && ! cmp -s "$tmp_dir/canonical.proto" "$tmp
exit 1
fi
if [[ -f "$tmp_dir/python.proto" ]] && ! cmp -s "$tmp_dir/canonical.proto" "$tmp_dir/python.proto"; then
echo "Proto schema mismatch: python/proto_socket/packets/message_common.proto must match proto/message_common.proto." >&2
diff -u "$tmp_dir/canonical.proto" "$tmp_dir/python.proto" >&2
exit 1
fi
echo "Proto schemas are in sync."

View file

@ -29,4 +29,9 @@ require_binary protoc-gen-go "Install Go plugin: go install google.golang.org/pr
protoc --go_out=. --go_opt=paths=source_relative packets/message_common.proto
)
(
cd "$repo_root/python"
protoc --python_out=. --pyi_out=. proto_socket/packets/message_common.proto
)
"$repo_root/tools/check_proto_sync.sh"

View file

@ -72,6 +72,14 @@ interface InboundItem {
responseNonce: number;
}
function getShortTypeName(typeName: string): string {
const lastDot = typeName.lastIndexOf(".");
if (lastDot < 0 || lastDot === typeName.length - 1) {
return typeName;
}
return typeName.substring(lastDot + 1);
}
export function parserFromSchema<T extends Message>(schema: MessageType<T>): MessageParser<T> {
const parser = ((data: Uint8Array) => fromBinary(schema, data)) as MessageParser<T>;
parser.schema = schema;
@ -86,6 +94,7 @@ export class Communicator {
private isAliveFlag = false;
private parserMap: ParserMap = new Map();
private schemaMap = new Map<string, MessageType>();
private canonicalNameMap = new Map<string, string>();
private handlers = new Map<string, Array<(msg: Message) => void>>();
private reqHandlers = new Map<string, RequestHandler>();
private pendingRequests = new Map<number, PendingRequest>();
@ -106,6 +115,12 @@ export class Communicator {
private closedPromise: Promise<void> = Promise.resolve();
private resolveClosed: (() => void) | null = null;
private canonicalize(typeName: string): string {
return this.canonicalNameMap.get(typeName) ??
this.canonicalNameMap.get(getShortTypeName(typeName)) ??
typeName;
}
initialize(transport: Transport, parserMap: ParserMap): void {
this.transport = transport;
this.closeGateway();
@ -127,16 +142,37 @@ export class Communicator {
this.isAliveFlag = true;
this.resetClosedPromise();
const tempParsers = new Map<string, MessageParser>();
for (const [typeName, parser] of parserMap) {
this.parserMap.set(typeName, parser);
if (parser.schema !== undefined) {
this.schemaMap.set(typeName, parser.schema);
tempParsers.set(typeName, parser);
}
const heartBeatParser = parserFromSchema(HeartBeatSchema);
tempParsers.set(HeartBeatSchema.typeName, heartBeatParser);
this.canonicalNameMap = new Map();
const shortToFull = new Map<string, string>();
for (const fullName of tempParsers.keys()) {
const shortName = getShortTypeName(fullName);
const existing = shortToFull.get(shortName);
if (existing !== undefined && existing !== fullName) {
throw new Error(`Duplicate alias mapping: ${shortName} maps to both ${existing} and ${fullName}`);
}
shortToFull.set(shortName, fullName);
}
const heartBeatParser = parserFromSchema(HeartBeatSchema);
this.parserMap.set(HeartBeatSchema.typeName, heartBeatParser);
this.schemaMap.set(HeartBeatSchema.typeName, HeartBeatSchema);
for (const fullName of tempParsers.keys()) {
this.canonicalNameMap.set(fullName, fullName);
const shortName = getShortTypeName(fullName);
this.canonicalNameMap.set(shortName, fullName);
}
for (const [typeName, parser] of tempParsers) {
const canonical = this.canonicalize(typeName);
this.parserMap.set(canonical, parser);
if (parser.schema !== undefined) {
this.schemaMap.set(canonical, parser.schema);
}
}
}
isAlive(): boolean {
@ -426,26 +462,29 @@ export class Communicator {
}
addListener(typeName: string, fn: (msg: Message) => void): void {
if (this.reqHandlers.has(typeName)) {
const canonical = this.canonicalize(typeName);
if (this.reqHandlers.has(canonical)) {
throw new Error(`type ${typeName} is already registered with addRequestListener`);
}
const handlers = this.handlers.get(typeName) ?? [];
const handlers = this.handlers.get(canonical) ?? [];
handlers.push(fn);
this.handlers.set(typeName, handlers);
this.handlers.set(canonical, handlers);
}
removeListeners(typeName: string): void {
this.handlers.delete(typeName);
const canonical = this.canonicalize(typeName);
this.handlers.delete(canonical);
}
addRequestListener(typeName: string, fn: RequestHandler): void {
if ((this.handlers.get(typeName) ?? []).length > 0) {
const canonical = this.canonicalize(typeName);
if ((this.handlers.get(canonical) ?? []).length > 0) {
throw new Error(`type ${typeName} is already registered with addListener`);
}
if (this.reqHandlers.has(typeName)) {
if (this.reqHandlers.has(canonical)) {
throw new Error(`type ${typeName} is already registered with addRequestListener`);
}
this.reqHandlers.set(typeName, fn);
this.reqHandlers.set(canonical, fn);
}
onReceivedData(typeName: string, data: Uint8Array, nonce = 0, responseNonce = 0): void {
@ -519,8 +558,9 @@ export class Communicator {
return;
}
const reqHandler = this.reqHandlers.get(typeName);
const listeners = [...(this.handlers.get(typeName) ?? [])];
const canonical = this.canonicalize(typeName);
const reqHandler = this.reqHandlers.get(canonical);
const listeners = [...(this.handlers.get(canonical) ?? [])];
if (reqHandler !== undefined) {
let message: Message;
@ -582,10 +622,12 @@ export class Communicator {
if (pending === undefined) {
return;
}
if (typeName !== pending.expectedTypeName) {
const canonicalExpected = this.canonicalize(pending.expectedTypeName);
const canonicalReceived = this.canonicalize(typeName);
if (canonicalReceived !== canonicalExpected) {
pending.reject(
new Error(
`response type mismatch for nonce ${responseNonce}: expected ${pending.expectedTypeName}, got ${typeName}`,
`response type mismatch for nonce ${responseNonce}: expected ${canonicalExpected}, got ${typeName}`,
),
);
return;
@ -599,7 +641,8 @@ export class Communicator {
}
private parse(typeName: string, data: Uint8Array): Message {
const parser = this.parserMap.get(typeName);
const canonical = this.canonicalize(typeName);
const parser = this.parserMap.get(canonical);
if (parser === undefined) {
throw new Error(`protobuf parser is not registered for type ${typeName}`);
}
@ -620,7 +663,8 @@ export class Communicator {
private encodeMessage(msg: Message): { typeName: string; data: Uint8Array } {
const typeName = typeNameOf(msg);
const schema = this.schemaMap.get(typeName);
const canonical = this.canonicalize(typeName);
const schema = this.schemaMap.get(canonical);
if (schema === undefined) {
throw new Error(`protobuf schema is not registered for type ${typeName}`);
}

View file

@ -18,25 +18,25 @@ export const file_message_common = {
messages: ["PacketBase", "HeartBeat", "TestData"],
} as const;
export type PacketBase = Message<"PacketBase"> & {
export type PacketBase = Message<"proto_socket.PacketBase"> & {
typeName: string;
nonce: number;
data: Uint8Array;
responseNonce: number;
};
export type HeartBeat = Message<"HeartBeat">;
export type HeartBeat = Message<"proto_socket.HeartBeat">;
export type TestData = Message<"TestData"> & {
export type TestData = Message<"proto_socket.TestData"> & {
index: number;
message: string;
};
export const PacketBaseSchema: MessageType<PacketBase> = {
typeName: "PacketBase",
typeName: "proto_socket.PacketBase",
create(value = {}) {
return {
$typeName: "PacketBase",
$typeName: "proto_socket.PacketBase",
typeName: value.typeName ?? "",
nonce: value.nonce ?? 0,
data: cloneBytes(value.data),
@ -52,9 +52,9 @@ export const PacketBaseSchema: MessageType<PacketBase> = {
};
export const HeartBeatSchema: MessageType<HeartBeat> = {
typeName: "HeartBeat",
typeName: "proto_socket.HeartBeat",
create() {
return { $typeName: "HeartBeat" };
return { $typeName: "proto_socket.HeartBeat" };
},
fromBinary(data) {
skipUnknownMessage(data);
@ -66,10 +66,10 @@ export const HeartBeatSchema: MessageType<HeartBeat> = {
};
export const TestDataSchema: MessageType<TestData> = {
typeName: "TestData",
typeName: "proto_socket.TestData",
create(value = {}) {
return {
$typeName: "TestData",
$typeName: "proto_socket.TestData",
index: value.index ?? 0,
message: value.message ?? "",
};

View file

@ -5,6 +5,7 @@ import {
NotConnectedError,
type Transport,
parserFromSchema,
type MessageParser,
} from "../src/communicator.js";
import {
type DecodedEnvelope,
@ -730,6 +731,54 @@ describe("Communicator inbound gateway", () => {
expect(received).toEqual([]);
await comm.close();
});
test("Alias collision during initialization triggers error", () => {
const comm = new Communicator();
const badParserMap = new Map<string, MessageParser>();
badParserMap.set("a.b.TestData", parserFromSchema(TestDataSchema));
badParserMap.set("x.y.TestData", parserFromSchema(TestDataSchema));
expect(() => {
comm.initialize(new MockTransport(), badParserMap);
}).toThrow();
});
test("Legacy simple-name alias routing and pending matching", async () => {
const comm = new Communicator();
comm.initialize(new MockTransport(), parserMap());
// 1. Test Listener routing with legacy simple name
const received: number[] = [];
comm.addListener(TestDataSchema.typeName, (msg) => {
received.push((msg as unknown as { index: number }).index);
});
const testMsgBytes = toBinary(TestDataSchema, create(TestDataSchema, { index: 42 }));
comm.onReceivedData("TestData", testMsgBytes, 1, 0);
for (let i = 0; i < 20; i += 1) {
if (received.length === 1) break;
await Promise.resolve();
}
expect(received).toEqual([42]);
// 2. Test Pending request matching with legacy simple name
const transport = new MockTransport();
const comm2 = new Communicator();
comm2.initialize(transport, parserMap());
const task = comm2.sendRequest(create(TestDataSchema, { index: 1 }), TestDataSchema, 1000);
await waitUntil(() => transport.packets.length === 1, 1000);
const request = transport.packets[0];
comm2.onReceivedData("TestData", testMsgBytes, 0, request.nonce);
const result = await task;
expect((result as unknown as { index: number }).index).toBe(42);
await comm.close();
await comm2.close();
});
});
describe("WorkerGateway lifecycle", () => {