diff --git a/PORTING_GUIDE.md b/PORTING_GUIDE.md index 8355952..a805a8d 100644 --- a/PORTING_GUIDE.md +++ b/PORTING_GUIDE.md @@ -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` + 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`, `SendRequestTyped` 패턴은 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) 방지에 유의한다 diff --git a/PROTOCOL.md b/PROTOCOL.md index a1afecd..e800851 100644 --- a/PROTOCOL.md +++ b/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. diff --git a/VERSIONING.md b/VERSIONING.md index 3a1c308..2a4d7f1 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -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. diff --git a/agent-roadmap/milestones/protocol-evolution-compatibility.md b/agent-roadmap/milestones/protocol-evolution-compatibility.md index 011579b..7fe9e87 100644 --- a/agent-roadmap/milestones/protocol-evolution-compatibility.md +++ b/agent-roadmap/milestones/protocol-evolution-compatibility.md @@ -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.`이다. - [ ] [legacy-alias] 수신 type registry에 full name과 simple name alias를 함께 등록하되, parser lookup, listener/request handler routing, response type matching, pending expected type 비교가 같은 alias 규칙을 사용하게 한다. simple alias 충돌 시 초기화 실패로 처리한다. diff --git a/agent-task/archive/2026/06/m-protocol-evolution-compatibility/01_proto_fullname_foundation/code_review_cloud_G06_0.log b/agent-task/archive/2026/06/m-protocol-evolution-compatibility/01_proto_fullname_foundation/code_review_cloud_G06_0.log new file mode 100644 index 0000000..15c7e34 --- /dev/null +++ b/agent-task/archive/2026/06/m-protocol-evolution-compatibility/01_proto_fullname_foundation/code_review_cloud_G06_0.log @@ -0,0 +1,174 @@ + + +# 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.`으로 맞춘다. +- [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-`이면 완료 이벤트 메타데이터를 보고하고 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` helper를 도입하여 generic type의 클래스명과 `_instanceGenerator`에 설정된 식별자(custom 패키지명 포함)를 맵과 연동하여 동적으로 가져오도록 개선하여 기존 커스텀 package-qualified 라우팅 테스트와 완벽하게 호환되도록 하였습니다. + +## 주요 설계 결정 + +- **Python `type_name_of` metadata**: `descriptor.name` 대신 `descriptor.full_name`을 사용하여 canonical proto name인 `proto_socket.`이 기본 타입명으로 송출되게 수정하였습니다. +- **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.`으로 고정됐는지 확인한다. +- `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/`로 이동한다. diff --git a/agent-task/archive/2026/06/m-protocol-evolution-compatibility/01_proto_fullname_foundation/complete.log b/agent-task/archive/2026/06/m-protocol-evolution-compatibility/01_proto_fullname_foundation/complete.log new file mode 100644 index 0000000..ee17ab8 --- /dev/null +++ b/agent-task/archive/2026/06/m-protocol-evolution-compatibility/01_proto_fullname_foundation/complete.log @@ -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.` 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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-protocol-evolution-compatibility/01_proto_fullname_foundation/plan_cloud_G06_0.log b/agent-task/archive/2026/06/m-protocol-evolution-compatibility/01_proto_fullname_foundation/plan_cloud_G06_0.log new file mode 100644 index 0000000..b09d458 --- /dev/null +++ b/agent-task/archive/2026/06/m-protocol-evolution-compatibility/01_proto_fullname_foundation/plan_cloud_G06_0.log @@ -0,0 +1,230 @@ + + +# 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.`으로 맞춘다. +- [ ] 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 = { + typeName: "PacketBase", +... +export const TestDataSchema: MessageType = { + 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-protocol-evolution-compatibility/02+01_legacy_alias/CODE_REVIEW-cloud-G07.md b/agent-task/m-protocol-evolution-compatibility/02+01_legacy_alias/CODE_REVIEW-cloud-G07.md new file mode 100644 index 0000000..3bfd2f2 --- /dev/null +++ b/agent-task/m-protocol-evolution-compatibility/02+01_legacy_alias/CODE_REVIEW-cloud-G07.md @@ -0,0 +1,137 @@ + + +# 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. diff --git a/agent-task/m-protocol-evolution-compatibility/02+01_legacy_alias/PLAN-cloud-G07.md b/agent-task/m-protocol-evolution-compatibility/02+01_legacy_alias/PLAN-cloud-G07.md new file mode 100644 index 0000000..2362955 --- /dev/null +++ b/agent-task/m-protocol-evolution-compatibility/02+01_legacy_alias/PLAN-cloud-G07.md @@ -0,0 +1,208 @@ + + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-protocol-evolution-compatibility/03+01,02_fullname_tests/CODE_REVIEW-cloud-G07.md b/agent-task/m-protocol-evolution-compatibility/03+01,02_fullname_tests/CODE_REVIEW-cloud-G07.md new file mode 100644 index 0000000..13feea7 --- /dev/null +++ b/agent-task/m-protocol-evolution-compatibility/03+01,02_fullname_tests/CODE_REVIEW-cloud-G07.md @@ -0,0 +1,111 @@ + + +# 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?** diff --git a/agent-task/m-protocol-evolution-compatibility/03+01,02_fullname_tests/PLAN-cloud-G07.md b/agent-task/m-protocol-evolution-compatibility/03+01,02_fullname_tests/PLAN-cloud-G07.md new file mode 100644 index 0000000..7906bf9 --- /dev/null +++ b/agent-task/m-protocol-evolution-compatibility/03+01,02_fullname_tests/PLAN-cloud-G07.md @@ -0,0 +1,180 @@ + + +# 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 =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/-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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/dart/lib/src/communicator.dart b/dart/lib/src/communicator.dart index e6555c7..c7653f6 100644 --- a/dart/lib/src/communicator.dart +++ b/dart/lib/src/communicator.dart @@ -30,6 +30,7 @@ abstract class Communicator { _requestHandlerDic = {}; Map _pendingRequests = {}; late Map)> _instanceGenerator; + Map _canonicalNameMap = {}; Future _outboundWrite = Future.value(); Transport? _transport; @@ -85,20 +86,59 @@ abstract class Communicator { void initialize( Map)> instanceGenerator, {required Transport transport}) { + final shortToFull = {}; + 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) getGenerator(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()}'); } return generator as T Function(List); } + String _qualifiedMessageNameOf() { + 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(Map map, String typeName) { - return map[typeName] ?? map[_shortTypeName(typeName)]; + return map[_canonicalize(typeName)]; } GeneratedMessage _decodeMessage( @@ -116,11 +156,11 @@ abstract class Communicator { List 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(); - final expectedResponseType = Res.toString(); + final expectedResponseType = _qualifiedMessageNameOf(); _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(Future Function(Req) handler) { - final reqType = Req.toString(); - if (_handlerDic.containsKey(reqType)) { + final reqType = _qualifiedMessageNameOf(); + 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 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(void Function(T) listener) { - var type = T.toString(); - if (_requestHandlerDic.containsKey(type)) { + var type = _qualifiedMessageNameOf(); + 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((typeName, data) { + if (!_handlerDic.containsKey(canonical)) { + _handlerDic[canonical] = DataHandler((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; + var handler = _handlerDic[canonical] as DataHandler; handler.addListener(listener); } void removeListener(void Function(T) listener) { - var type = T.toString(); - if (_handlerDic.containsKey(type)) { - var handler = _handlerDic[type] as DataHandler; + var type = _qualifiedMessageNameOf(); + var canonical = _canonicalize(type); + if (_handlerDic.containsKey(canonical)) { + var handler = _handlerDic[canonical] as DataHandler; handler.removeListener(listener); } } diff --git a/dart/lib/src/packets/message_common.pb.dart b/dart/lib/src/packets/message_common.pb.dart index 58665a5..24f34e5 100644 --- a/dart/lib/src/packets/message_common.pb.dart +++ b/dart/lib/src/packets/message_common.pb.dart @@ -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') diff --git a/dart/lib/src/protobuf_client.dart b/dart/lib/src/protobuf_client.dart index c30d468..938840f 100644 --- a/dart/lib/src/protobuf_client.dart +++ b/dart/lib/src/protobuf_client.dart @@ -50,7 +50,7 @@ abstract class ProtobufClient extends BaseClient { _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()는 diff --git a/dart/lib/src/ws_protobuf_client_io.dart b/dart/lib/src/ws_protobuf_client_io.dart index bf9ac87..afff5d5 100644 --- a/dart/lib/src/ws_protobuf_client_io.dart +++ b/dart/lib/src/ws_protobuf_client_io.dart @@ -45,7 +45,7 @@ abstract class WsProtobufClient extends BaseClient { 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되지 않는다. diff --git a/dart/lib/src/ws_protobuf_client_web.dart b/dart/lib/src/ws_protobuf_client_web.dart index 38ed850..3704e82 100644 --- a/dart/lib/src/ws_protobuf_client_web.dart +++ b/dart/lib/src/ws_protobuf_client_web.dart @@ -68,7 +68,7 @@ abstract class WsProtobufClient extends BaseClient { 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 diff --git a/dart/test/communicator_test.dart b/dart/test/communicator_test.dart index fc87ca6..85f2fc6 100644 --- a/dart/test/communicator_test.dart +++ b/dart/test/communicator_test.dart @@ -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()), + ); + }); + + 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((m) { + listenerCalled = true; + receivedMsg = m; + }); + + final testMsgBytes = (TestData()..index = 42..message = 'test').writeToBuffer(); + communicator.onReceivedData('TestData', testMsgBytes, incomingNonce: 1); + + await Future.delayed(Duration.zero); + expect(listenerCalled, isTrue); + expect(receivedMsg.index, 42); + + // 2. Test Pending request matching with legacy simple name + final future = communicator.sendRequest( + TestData()..index = 1, + ); + + await Future.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(); + }); }); } diff --git a/go/communicator.go b/go/communicator.go index ce891a6..dc36976 100644 --- a/go/communicator.go +++ b/go/communicator.go @@ -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) diff --git a/go/packets/message_common.pb.go b/go/packets/message_common.pb.go index 95c9a53..8fbfd3a 100644 --- a/go/packets/message_common.pb.go +++ b/go/packets/message_common.pb.go @@ -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 diff --git a/go/packets/message_common.proto b/go/packets/message_common.proto index b9e02ca..3facf1a 100644 --- a/go/packets/message_common.proto +++ b/go/packets/message_common.proto @@ -1,5 +1,7 @@ syntax = "proto3"; +package proto_socket; + option go_package = "git.toki-labs.com/toki/proto-socket/go/packets"; message PacketBase { diff --git a/go/test/communicator_test.go b/go/test/communicator_test.go index 84ddc12..4c283a5 100644 --- a/go/test/communicator_test.go +++ b/go/test/communicator_test.go @@ -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) + } +} diff --git a/go/test/test_helpers_test.go b/go/test/test_helpers_test.go index 7c23fca..574ad68 100644 --- a/go/test/test_helpers_test.go +++ b/go/test/test_helpers_test.go @@ -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) } } diff --git a/kotlin/src/main/kotlin/com/tokilabs/proto_socket/Communicator.kt b/kotlin/src/main/kotlin/com/tokilabs/proto_socket/Communicator.kt index eccfc8a..8819732 100644 --- a/kotlin/src/main/kotlin/com/tokilabs/proto_socket/Communicator.kt +++ b/kotlin/src/main/kotlin/com/tokilabs/proto_socket/Communicator.kt @@ -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() 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.parseFrom(it) } + val shortToFull = mutableMapOf() + val tempParsers = parserMap.toMutableMap() + tempParsers[typeNameOf()] = { 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) } diff --git a/kotlin/src/main/proto/message_common.proto b/kotlin/src/main/proto/message_common.proto index 1e38a71..3e1bff5 100644 --- a/kotlin/src/main/proto/message_common.proto +++ b/kotlin/src/main/proto/message_common.proto @@ -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; diff --git a/kotlin/src/test/kotlin/com/tokilabs/proto_socket/CommunicatorTest.kt b/kotlin/src/test/kotlin/com/tokilabs/proto_socket/CommunicatorTest.kt index 719b6d8..035afd3 100644 --- a/kotlin/src/test/kotlin/com/tokilabs/proto_socket/CommunicatorTest.kt +++ b/kotlin/src/test/kotlin/com/tokilabs/proto_socket/CommunicatorTest.kt @@ -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 { + 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(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( + 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() } } diff --git a/proto/message_common.proto b/proto/message_common.proto index cf5e741..b2194c5 100644 --- a/proto/message_common.proto +++ b/proto/message_common.proto @@ -1,5 +1,7 @@ syntax = "proto3"; +package proto_socket; + message PacketBase { string typeName = 1; int32 nonce = 2; diff --git a/python/proto_socket/communicator.py b/python/proto_socket/communicator.py index 6cef9f8..d0997a4 100644 --- a/python/proto_socket/communicator.py +++ b/python/proto_socket/communicator.py @@ -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: diff --git a/python/proto_socket/packets/message_common.proto b/python/proto_socket/packets/message_common.proto index c3b2243..e25fe2e 100644 --- a/python/proto_socket/packets/message_common.proto +++ b/python/proto_socket/packets/message_common.proto @@ -1,5 +1,7 @@ syntax = "proto3"; +package proto_socket; + message PacketBase { string typeName = 1; int32 nonce = 2; diff --git a/python/proto_socket/packets/message_common_pb2.py b/python/proto_socket/packets/message_common_pb2.py index c056bec..4577f39 100644 --- a/python/proto_socket/packets/message_common_pb2.py +++ b/python/proto_socket/packets/message_common_pb2.py @@ -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) diff --git a/python/test/test_communicator.py b/python/test/test_communicator.py index 9fb498a..b24302d 100644 --- a/python/test/test_communicator.py +++ b/python/test/test_communicator.py @@ -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() diff --git a/tools/check_proto_sync.sh b/tools/check_proto_sync.sh index 929ba32..04e8d95 100755 --- a/tools/check_proto_sync.sh +++ b/tools/check_proto_sync.sh @@ -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." diff --git a/tools/generate_proto.sh b/tools/generate_proto.sh index 9f4e031..f05bf0f 100755 --- a/tools/generate_proto.sh +++ b/tools/generate_proto.sh @@ -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" diff --git a/typescript/src/communicator.ts b/typescript/src/communicator.ts index 3b72ca0..cac35a6 100644 --- a/typescript/src/communicator.ts +++ b/typescript/src/communicator.ts @@ -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(schema: MessageType): MessageParser { const parser = ((data: Uint8Array) => fromBinary(schema, data)) as MessageParser; parser.schema = schema; @@ -86,6 +94,7 @@ export class Communicator { private isAliveFlag = false; private parserMap: ParserMap = new Map(); private schemaMap = new Map(); + private canonicalNameMap = new Map(); private handlers = new Map void>>(); private reqHandlers = new Map(); private pendingRequests = new Map(); @@ -106,6 +115,12 @@ export class Communicator { private closedPromise: Promise = 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(); 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(); + 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}`); } diff --git a/typescript/src/packets/message_common_pb.ts b/typescript/src/packets/message_common_pb.ts index bb89e65..cf211fb 100644 --- a/typescript/src/packets/message_common_pb.ts +++ b/typescript/src/packets/message_common_pb.ts @@ -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 = { - 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 = { }; export const HeartBeatSchema: MessageType = { - 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 = { }; export const TestDataSchema: MessageType = { - typeName: "TestData", + typeName: "proto_socket.TestData", create(value = {}) { return { - $typeName: "TestData", + $typeName: "proto_socket.TestData", index: value.index ?? 0, message: value.message ?? "", }; diff --git a/typescript/test/communicator.test.ts b/typescript/test/communicator.test.ts index 390755e..d7c63a1 100644 --- a/typescript/test/communicator.test.ts +++ b/typescript/test/communicator.test.ts @@ -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(); + 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", () => {