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:
parent
588ac5e95b
commit
6cdd0c3581
35 changed files with 1823 additions and 152 deletions
|
|
@ -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) 방지에 유의한다
|
||||
|
|
|
|||
36
PROTOCOL.md
36
PROTOCOL.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 충돌 시 초기화 실패로 처리한다.
|
||||
|
|
|
|||
|
|
@ -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/`로 이동한다.
|
||||
|
|
@ -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
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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?**
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()는
|
||||
|
|
|
|||
|
|
@ -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되지 않는다.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
syntax = "proto3";
|
||||
|
||||
package proto_socket;
|
||||
|
||||
option go_package = "git.toki-labs.com/toki/proto-socket/go/packets";
|
||||
|
||||
message PacketBase {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
syntax = "proto3";
|
||||
|
||||
package proto_socket;
|
||||
|
||||
message PacketBase {
|
||||
string typeName = 1;
|
||||
int32 nonce = 2;
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
syntax = "proto3";
|
||||
|
||||
package proto_socket;
|
||||
|
||||
message PacketBase {
|
||||
string typeName = 1;
|
||||
int32 nonce = 2;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ?? "",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue