feat: inbound queue ordering - Go gateway implementation
- Add inbound_gateway.go and inbound_gateway_test.go - Update communicator.go with nonce handling improvements - Update tcp_client.go and ws_client.go - Update communicator_test.go and ws_test.go - Move old Go gateway docs to archive
This commit is contained in:
parent
bdcebdeb56
commit
2ec43f2d00
15 changed files with 1546 additions and 81 deletions
|
|
@ -0,0 +1,122 @@
|
|||
<!-- task=m-inbound-queue-ordering/05_go_gateway plan=0 tag=API -->
|
||||
|
||||
# Code Review Reference - API
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST]** 구현 후 이 파일의 구현 에이전트 소유 섹션을 실제 내용과 검증 출력으로 채우고 active 파일을 그대로 둔 채 리뷰를 요청한다. 최종화는 code-review 전용이다.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-06-02
|
||||
task=m-inbound-queue-ordering/05_go_gateway, plan=0, tag=API
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/milestones/inbound-queue-ordering.md`
|
||||
- Task ids:
|
||||
- `go-gateway`: Go goroutine worker pool gateway 후보를 적용한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [API-1] Go gateway worker pool | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] Go worker gateway 타입과 내부 `seq` 포함 input/output 구조를 추가한다.
|
||||
- [x] worker pool은 raw bytes decode/순수 전처리만 수행하고, pending/listener/request/write queue 상태를 소유하지 않는다.
|
||||
- [x] gateway 결과를 `seq` 기준으로 reorder한 뒤 기존 `EnqueueInbound`/receive coordinator로 전달한다.
|
||||
- [x] Go tests에 대량 packet decode, out-of-order worker completion, race-safe dispatch 순서 검증을 추가한다.
|
||||
- [x] 중간 검증으로 `cd go && go test -count=1 ./... && go test -race ./...`를 실행하고 실제 stdout/stderr를 기록한다.
|
||||
- [x] 최종 검증으로 `bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all`을 실행하고 결과 기록 파일을 남긴다.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [x] active review/plan을 `.log`로 아카이브한다.
|
||||
- [ ] PASS이면 `complete.log` 작성 후 task directory를 archive로 이동한다.
|
||||
- [ ] PASS이면 runtime completion metadata를 보고하고 roadmap은 직접 수정하지 않는다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
- 계획의 "필요 시 Go gateway 전용 파일"을 실제로 채택해 gateway 타입과 worker pool을 새 파일 `go/inbound_gateway.go`에 분리했다. `communicator.go`에는 통합 진입점(`EnableInboundGateway`, `AttachInboundGateway`, `OnReceivedFrame`, `closeGateway`)과 필드(`gateway`, `frameSeq`)만 추가했다.
|
||||
- 계획에 명시되지 않았던 디코드 실패 처리(`workerResult.err`로 seq 슬롯만 advance하고 sink로는 전달하지 않음)와 그 회귀 테스트(`TestWorkerGatewaySkipsDecodeErrors`)를 추가했다. 한 프레임의 디코드 실패가 reorder window를 영구 정지시키지 못하게 하기 위함이다.
|
||||
- 테스트는 계획의 `go/test/communicator_test.go`(외부 패키지 통합 테스트)에 더해, reorder 버퍼와 out-of-order 완료를 결정론적으로 검증하기 위한 내부 패키지 테스트 `go/inbound_gateway_test.go`를 추가했다. 루트 패키지 테스트는 go 도메인 규칙상 `go/*.go`에 둘 수 있다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- **gateway = 순수 decode, coordinator = dispatch 소유.** worker는 `decodePacketBase`(PacketBase 언마샬)만 수행하고 `pendingRequests`/listeners/`writeQueue`에 접근하지 않는다. 결과는 단일 collector goroutine이 `frameReorderBuffer`로 `seq` 순서 복원 후 sink로 넘기고, sink는 기존 `OnReceivedData`(→ response면 `handleResponse`, 아니면 `EnqueueInbound`)로 라우팅한다. 따라서 stateful dispatch와 출력 순서는 기존 receive coordinator가 단독 소유한다.
|
||||
- **순서 보장은 단일 collector로 직렬화.** 다수 worker가 out-of-order로 완료해도 sink를 호출하는 goroutine은 collector 하나뿐이고, `frameReorderBuffer`가 1부터 연속된 prefix만 방출하므로 sink 호출은 항상 입력 `seq` 순서다. mutex로 sink를 감싸지 않아 sink(=`EnqueueInbound` 채널)가 blocking돼도 다른 worker의 디코드를 막지 않는다.
|
||||
- **backpressure는 bounded channel로.** `jobs`/`results` 채널 capacity로 자연 backpressure가 걸리며, `Submit`은 채널이 차면 block하되 `done` close 시 즉시 반환한다.
|
||||
- **opt-in.** gateway 미부착 시 `OnReceivedFrame`은 호출 goroutine에서 inline 디코드하므로 기존 동작과 wire format에 영향이 없다. `seq`/`frameSeq`는 내부 정렬 전용이며 wire field가 아니다.
|
||||
- **race-safety.** `gateway` 필드는 `c.mu`로 보호하고, `frameSeq`는 `atomic.Int64`, worker↔collector 통신은 채널만 사용한다. `go test -race ./...` 무경합 확인.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- gateway가 stateful dispatcher 책임을 갖지 않는지 확인한다.
|
||||
- `go test -race ./...` 결과가 실제 출력인지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
### API-1 중간 검증
|
||||
```
|
||||
$ cd go && go test -count=1 ./...
|
||||
ok git.toki-labs.com/toki/proto-socket/go 0.042s
|
||||
? git.toki-labs.com/toki/proto-socket/go/crosstest/dart_go_client [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/crosstest/kotlin_go_client [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/crosstest/python_go_client [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/crosstest/typescript_go_client [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/examples/tcp_echo [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/examples/ws_echo [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/packets [no test files]
|
||||
ok git.toki-labs.com/toki/proto-socket/go/test 8.189s
|
||||
|
||||
$ cd go && go test -race ./...
|
||||
ok git.toki-labs.com/toki/proto-socket/go 1.052s
|
||||
? git.toki-labs.com/toki/proto-socket/go/crosstest/dart_go_client [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/crosstest/kotlin_go_client [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/crosstest/python_go_client [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/crosstest/typescript_go_client [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/examples/tcp_echo [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/examples/ws_echo [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/packets [no test files]
|
||||
ok git.toki-labs.com/toki/proto-socket/go/test 9.234s
|
||||
```
|
||||
|
||||
추가된 테스트: `TestFrameReorderBufferReleasesInOrder`, `TestWorkerGatewayReordersOutOfOrderCompletion`, `TestWorkerGatewaySkipsDecodeErrors`(go/inbound_gateway_test.go), `TestInboundGatewayPreservesDispatchOrder`(go/test/communicator_test.go).
|
||||
|
||||
### 최종 검증
|
||||
```
|
||||
$ bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all
|
||||
```
|
||||
|
||||
- 결과: 전 항목 PASS. Proto 동기화 PASS, 동일언어 5/5 PASS, 언어 PASS 매트릭스 5x7 전부 PASS, 크로스테스트 30방향 전부 expected 일치 PASS.
|
||||
- 결과 기록 파일: `agent-test/runs/20260602-015124-proto-socket-full-matrix.md`
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: FAIL
|
||||
- 차원별 평가:
|
||||
- correctness: Fail
|
||||
- completeness: Fail
|
||||
- test coverage: Fail
|
||||
- API contract: Warn
|
||||
- code quality: Pass
|
||||
- plan deviation: Fail
|
||||
- verification trust: Pass
|
||||
- 발견된 문제:
|
||||
- Required: `go/tcp_client.go:83`와 `go/ws_client.go:98`의 실제 transport read loop가 여전히 raw frame을 직접 `proto.Unmarshal`한 뒤 `OnReceivedData`로 전달한다. 따라서 `EnableInboundGateway`/`AttachInboundGateway`를 호출해도 TCP/WS 실연결 수신 경로는 gateway를 전혀 통과하지 않아, 계획의 worker gateway 적용 및 `seq` reorder 후 coordinator 전달 조건이 충족되지 않는다. read loop가 gateway 부착 여부를 반영하는 raw-frame 진입점을 사용하도록 연결하고, 기존 parse 실패 disconnect semantics도 유지해야 한다.
|
||||
- Required: `go/test/communicator_test.go:333`의 신규 통합 테스트는 `Communicator.OnReceivedFrame`을 직접 호출해서 새 helper만 검증한다. 실제 `TcpClient.readLoop`/`WsClient.readLoop`가 gateway를 사용하는지 검증하지 못하므로 위 누락을 잡지 못한다. TCP 또는 WS 실제 read path에서 gateway가 부착된 경우 raw frame이 gateway sink를 거쳐 dispatch되는 테스트와 parse error 동작 테스트를 추가해야 한다.
|
||||
- 다음 단계: WARN/FAIL follow-up으로 다음 active `PLAN-cloud-G08.md`와 `CODE_REVIEW-cloud-G08.md`를 작성한다.
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
<!-- task=m-inbound-queue-ordering/05_go_gateway plan=1 tag=REVIEW_API -->
|
||||
|
||||
# Code Review Reference - REVIEW_API
|
||||
|
||||
> **[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.
|
||||
> 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-02
|
||||
task=m-inbound-queue-ordering/05_go_gateway, plan=1, tag=REVIEW_API
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/milestones/inbound-queue-ordering.md`
|
||||
- Task ids:
|
||||
- `go-gateway`: Go goroutine worker pool gateway 후보를 적용한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-{review_lane}-GNN.md` -> `code_review_{review_lane}_GNN_N.log`, `PLAN-{build_lane}-GNN.md` -> `plan_{build_lane}_GNN_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
|
||||
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [REVIEW_API-1] 실제 transport read path gateway 연결 | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] TCP/WS read loop가 gateway 부착 시 raw frame을 gateway 경로로 전달하고, gateway 미부착 시 기존 inline decode 동작을 유지한다.
|
||||
- [x] raw frame decode 실패 시 기존 TCP/WS parse error disconnect semantics가 유지되도록 error 처리 경계를 보완한다.
|
||||
- [x] 실제 TCP 또는 WS read path에서 `EnableInboundGateway`가 dispatch 순서를 보존하는 테스트를 추가한다.
|
||||
- [x] gateway read path 테스트가 `OnReceivedFrame` 직접 호출만으로 통과하지 않고 transport read loop 연결 누락을 잡을 수 있게 구성한다.
|
||||
- [x] 중간 검증으로 `cd go && go test -count=1 ./... && go test -race ./...`를 실행하고 실제 stdout/stderr를 기록한다.
|
||||
- [x] 최종 검증으로 `bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all`을 실행하고 결과 기록 파일을 남긴다.
|
||||
- [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_{review_lane}_GNN_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다.
|
||||
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하여 plan/review/archive 산출물이 추적 가능한지 확인한다.
|
||||
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [ ] PASS이면 active task 디렉터리 `agent-task/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
|
||||
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
- `code_review_cloud_G08_0.log`의 Required 2건만 해결했다. gateway worker pool 자체(plan=0 산출물)는 유지하고, 실제 transport read loop 연결 누락만 보완했다.
|
||||
- `Communicator.OnReceivedFrame`의 시그니처를 `(raw []byte)` → `(raw []byte) error`로 바꿨다. inline decode 실패 시 error를 반환해 transport가 기존 parse-error disconnect를 그대로 적용하게 했다. gateway 부착 시에는 비동기 decode이므로 항상 nil을 반환하고, 디코드 실패 프레임은 gateway worker가 drop한다(연결을 끊지 않음).
|
||||
- `go/tcp_client.go`, `go/ws_client.go`의 read loop가 직접 `proto.Unmarshal` + `OnReceivedData` 하던 부분을 `c.OnReceivedFrame(raw)` 호출로 교체했다. inline 모드 동작과 disconnect reason(`DisconnectReasonTCPPacketParse`/`DisconnectReasonWSPacketParse`)은 동일하게 보존된다.
|
||||
- `go/test/communicator_test.go`에 실제 `TcpClient.readLoop`(`net.Pipe` 기반) 테스트 3종을 추가했다. plan=0의 `TestInboundGatewayPreservesDispatchOrder`(직접 `OnReceivedFrame` 호출)는 gateway+coordinator 통합 단위 테스트로 유지했다.
|
||||
- `go/inbound_gateway.go`는 plan에 후보 파일로 적혔으나 이번 Required 해결에는 추가 변경이 필요 없어 수정하지 않았다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- **read loop는 raw-frame 진입점 하나로 단일화.** `OnReceivedFrame`이 gateway 부착 여부를 내부에서 분기하므로, TCP/WS read loop는 동일한 한 줄로 두 모드를 모두 처리한다. gateway 부착 → worker pool submit, 미부착 → inline decode 후 coordinator 전달.
|
||||
- **parse-error disconnect 경계는 inline 경로에 보존.** `OnReceivedFrame`이 inline decode 실패 시 error를 반환하고 read loop가 기존 reason으로 `onDisconnected`를 호출한다. gateway 부착 시 malformed 프레임은 worker가 비동기로 drop한다 — gateway는 순수 decode만 하고 transport teardown/dispatch를 소유하지 않는다는 계약을 깨지 않기 위한 의도된 trade-off다. disconnect 의미 검증 테스트는 기본(미부착) 경로에서 수행한다.
|
||||
- **wiring 누락을 잡는 테스트.** `TestTcpReadLoopRoutesThroughGateway`는 `AttachInboundGateway`로 submit 횟수를 세는 test gateway를 붙인다. read loop가 gateway를 우회해 inline decode하면 submit 수가 0이 되어 실패하므로, 이전 리뷰가 지적한 연결 누락을 회귀로 잡는다. 단순 순서 보존 테스트는 read loop가 순차적이라 우회해도 통과하므로 wiring 검증에 불충분하다는 점을 반영했다.
|
||||
- **heartbeat 무소음 테스트 환경.** `intervalSec=0`이면 `baseClient.sendHeartBeat`가 즉시 반환하므로 `net.Pipe` 위 client가 파이프에 아무것도 쓰지 않아 테스트가 deadlock 없이 read path만 검증한다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- TCP/WS 실제 read loop가 gateway 부착 시 raw frame을 gateway로 전달하는지 확인한다.
|
||||
- gateway 미부착 시 기존 inline decode와 parse failure disconnect semantics가 유지되는지 확인한다.
|
||||
- 신규 테스트가 `OnReceivedFrame` 직접 호출만으로 통과하지 않는지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
### REVIEW_API-1 중간 검증
|
||||
```
|
||||
$ cd go && go test -count=1 ./...
|
||||
ok git.toki-labs.com/toki/proto-socket/go 0.043s
|
||||
? git.toki-labs.com/toki/proto-socket/go/crosstest/dart_go_client [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/crosstest/kotlin_go_client [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/crosstest/python_go_client [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/crosstest/typescript_go_client [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/examples/tcp_echo [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/examples/ws_echo [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/packets [no test files]
|
||||
ok git.toki-labs.com/toki/proto-socket/go/test 8.203s
|
||||
|
||||
$ cd go && go test -race ./...
|
||||
ok git.toki-labs.com/toki/proto-socket/go 1.052s
|
||||
? git.toki-labs.com/toki/proto-socket/go/crosstest/dart_go_client [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/crosstest/kotlin_go_client [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/crosstest/python_go_client [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/crosstest/typescript_go_client [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/examples/tcp_echo [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/examples/ws_echo [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/packets [no test files]
|
||||
ok git.toki-labs.com/toki/proto-socket/go/test 9.222s
|
||||
```
|
||||
|
||||
신규 transport read-path 테스트(실제 `TcpClient.readLoop`, `net.Pipe`):
|
||||
```
|
||||
$ go test -count=1 -v -run 'TestTcpReadLoop' ./test/
|
||||
=== RUN TestTcpReadLoopRoutesThroughGateway
|
||||
--- PASS: TestTcpReadLoopRoutesThroughGateway (0.00s)
|
||||
=== RUN TestTcpReadLoopWorkerGatewayPreservesOrder
|
||||
--- PASS: TestTcpReadLoopWorkerGatewayPreservesOrder (0.00s)
|
||||
=== RUN TestTcpReadLoopParseErrorDisconnects
|
||||
--- PASS: TestTcpReadLoopParseErrorDisconnects (0.00s)
|
||||
PASS
|
||||
ok git.toki-labs.com/toki/proto-socket/go/test 0.010s
|
||||
```
|
||||
|
||||
- `TestTcpReadLoopRoutesThroughGateway`: read loop가 gateway로 raw frame을 submit하는지(우회 시 submit=0으로 실패) + 순서 보존.
|
||||
- `TestTcpReadLoopWorkerGatewayPreservesOrder`: 실제 `EnableInboundGateway`(worker pool)로 100개 burst의 dispatch 순서 보존.
|
||||
- `TestTcpReadLoopParseErrorDisconnects`: gateway 미부착 시 malformed frame이 `DisconnectReasonTCPPacketParse`로 disconnect.
|
||||
|
||||
### 최종 검증
|
||||
```
|
||||
$ bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all
|
||||
```
|
||||
|
||||
- 결과: 전 항목 PASS. Proto 동기화 PASS, 동일언어 5/5 PASS, 언어 PASS 매트릭스 5x7 전부 PASS, 크로스테스트 30방향 전부 expected 일치 PASS.
|
||||
- 결과 기록 파일: `agent-test/runs/20260602-021055-proto-socket-full-matrix.md`
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: FAIL
|
||||
- 차원별 평가:
|
||||
- correctness: Fail
|
||||
- completeness: Fail
|
||||
- test coverage: Fail
|
||||
- API contract: Fail
|
||||
- code quality: Pass
|
||||
- plan deviation: Fail
|
||||
- verification trust: Pass
|
||||
- 발견된 문제:
|
||||
- Required: `go/communicator.go:327`과 `go/inbound_gateway.go:157`에서 gateway 부착 시 malformed raw frame이 worker에서 decode error로 표시된 뒤 collector에서 조용히 drop된다. 이 경로에서는 `OnReceivedFrame`이 항상 nil을 반환하므로 `TcpClient.readLoop`/`WsClient.readLoop`가 기존 `DisconnectReasonTCPPacketParse`/`DisconnectReasonWSPacketParse` disconnect를 적용할 수 없다. follow-up 계획의 "raw frame decode 실패 시 기존 TCP/WS parse error disconnect semantics 유지" 체크리스트가 gateway 부착 경로에서는 충족되지 않는다. gateway decode error를 transport/communicator가 관찰해 동일한 parse-error disconnect를 실행하도록 error callback/result sink를 추가하거나, gateway submit 전에 parse validation을 분리해 기존 semantics를 보존해야 한다.
|
||||
- Required: `go/test/communicator_test.go:518`의 parse-error 회귀 테스트는 "with no gateway" 경로만 검증한다. 실제로 문제가 생기는 gateway 부착 read path에서 malformed frame이 parse-error disconnect를 유발하는 테스트가 없다. TCP gateway 부착 경로의 malformed frame 테스트와, 가능하면 WS gateway 부착 경로도 같은 semantics를 확인하는 테스트를 추가해야 한다.
|
||||
- 다음 단계: WARN/FAIL follow-up으로 다음 active `PLAN-cloud-G08.md`와 `CODE_REVIEW-cloud-G08.md`를 작성한다.
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
<!-- task=m-inbound-queue-ordering/05_go_gateway plan=2 tag=REVIEW_REVIEW_API -->
|
||||
|
||||
# Code Review Reference - REVIEW_REVIEW_API
|
||||
|
||||
> **[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.
|
||||
> 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-02
|
||||
task=m-inbound-queue-ordering/05_go_gateway, plan=2, tag=REVIEW_REVIEW_API
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/milestones/inbound-queue-ordering.md`
|
||||
- Task ids:
|
||||
- `go-gateway`: Go goroutine worker pool gateway 후보를 적용한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-{review_lane}-GNN.md` -> `code_review_{review_lane}_GNN_N.log`, `PLAN-{build_lane}-GNN.md` -> `plan_{build_lane}_GNN_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
|
||||
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
| -----------------------------------------------------------| -----------|
|
||||
| [REVIEW_REVIEW_API-1] gateway decode error semantics 보존 | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] gateway 부착 read path에서 raw frame decode 실패가 기존 TCP/WS parse-error disconnect semantics로 이어지도록 error propagation 경계를 보완한다.
|
||||
- [x] gateway worker는 stateful dispatch를 소유하지 않고, decode error 전달도 transport/communicator teardown 책임 경계를 침범하지 않게 구성한다.
|
||||
- [x] TCP gateway 부착 read path에서 malformed frame이 `DisconnectReasonTCPPacketParse`로 disconnect되는 테스트를 추가한다.
|
||||
- [x] WS gateway 부착 read path의 malformed frame semantics를 테스트하거나, WS를 생략한다면 TCP와 같은 `OnReceivedFrame` error path를 공유한다는 근거를 구현 기록에 남긴다.
|
||||
- [x] 기존 gateway 순서 보존, worker decode error reorder advance, read loop wiring 테스트가 계속 통과하는지 확인한다.
|
||||
- [x] 중간 검증으로 `cd go && go test -count=1 ./... && go test -race ./...`를 실행하고 실제 stdout/stderr를 기록한다.
|
||||
- [x] 최종 검증으로 `bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all`을 실행하고 결과 기록 파일을 남긴다.
|
||||
- [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_{review_lane}_GNN_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다.
|
||||
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하여 plan/review/archive 산출물이 추적 가능한지 확인한다.
|
||||
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [x] PASS이면 active task 디렉터리 `agent-task/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
|
||||
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
- `code_review_cloud_G08_1.log`의 Required 2건만 해결했다. 이전 trade-off(gateway 부착 시 malformed frame을 worker가 조용히 drop)를 폐기하고, gateway decode error를 transport가 관찰해 동일한 parse-error disconnect를 실행하도록 error propagation 경계를 추가했다.
|
||||
- `go/inbound_gateway.go`: `WorkerGateway`에 `onError func(error)` 콜백을 추가하고 `NewWorkerGateway` 시그니처를 `(workers, queueSize, sink, onError)`로 확장했다. collector가 decode error result를 `seq` 순서로 `onError`에 전달하고, 이후 프레임은 계속 처리한다(reorder window를 막지 않음). gateway는 여전히 transport teardown을 직접 하지 않는다.
|
||||
- `go/communicator.go`: `writeErrHandler`와 동일한 패턴으로 `frameErrHandler` 필드와 `SetFrameErrorHandler`/`onFrameError`를 추가했다. `EnableInboundGateway`가 worker gateway의 `onError`를 `onFrameError`(→ 등록된 frame error handler)로 연결한다. `OnReceivedFrame` 문서 주석을 "gateway 모드 decode error는 drop"에서 "frame error handler로 비동기 전파"로 수정했다.
|
||||
- `go/tcp_client.go`, `go/ws_client.go`: 생성자에서 `SetWriteErrorHandler` 바로 옆에 `SetFrameErrorHandler`를 등록해, gateway decode error가 각각 `DisconnectReasonTCPPacketParse`/`DisconnectReasonWSPacketParse`로 disconnect되게 했다.
|
||||
- `go/inbound_gateway_test.go`: `TestWorkerGatewaySkipsDecodeErrors`를 `TestWorkerGatewayReportsDecodeErrorAndAdvances`로 갱신해 error가 sink가 아닌 `onError`로 1회 전달되고 이후 good frame은 순서대로 sink에 도달함을 검증한다. 기존 `newWorkerGateway` 호출에 `onError` 인자(nil)를 추가했다.
|
||||
- `go/test/communicator_test.go`: `TestTcpReadLoopGatewayParseErrorDisconnects`(gateway 부착 + malformed frame → `DisconnectReasonTCPPacketParse`)를 추가했다. gateway 미부착 경로 테스트(`TestTcpReadLoopParseErrorDisconnects`)는 유지한다.
|
||||
- `go/test/ws_test.go`: 실제 WS read path 테스트 `TestWsReadLoopGatewayParseErrorDisconnects`를 추가했다. in-process `NewWsServer`에서 accept된 WsClient에 gateway를 부착하고, raw `websocket.Dial` conn으로 malformed binary frame을 보내 server측이 `DisconnectReasonWSPacketParse`로 disconnect됨을 확인한다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- **error 전파는 `writeErrHandler` 패턴 재사용.** gateway decode error는 transport-specific disconnect reason을 모르므로, transport가 `SetFrameErrorHandler`로 핸들러를 등록하고 gateway `onError`가 이를 호출한다. 이로써 gateway는 순수 decode/reorder만 담당하고 transport teardown 결정은 transport가 소유한다 — listener/request/pending/write queue 상태를 침범하지 않는다.
|
||||
- **error도 `seq` 순서로 전달.** collector(단일 goroutine)가 error result를 `seq` 순서 슬롯에서 `onError`에 넘기므로, malformed frame 이전 seq의 정상 dispatch는 그대로 유지되고 disconnect는 올바른 지점에서 트리거된다.
|
||||
- **error 후에도 reorder window는 진행.** collector는 error를 보고한 뒤 `continue`해 다음 frame을 막지 않는다(단일 frame 실패가 stall을 만들지 않음). transport 경로에서는 `onError` → disconnect가 즉시 `IsAlive`를 false로 만들어 이후 frame은 coordinator(`EnqueueInbound`)에서 자연히 무시되므로, "stall 없음"과 "parse error 시 dispatch 중단"이 동시에 충족된다.
|
||||
- **inline 경로 불변.** gateway 미부착 시 `OnReceivedFrame`은 여전히 error를 반환하고 read loop가 직접 disconnect한다. gateway 부착 시에만 `frameErrHandler` 경로가 동작하므로 이중 disconnect는 없다.
|
||||
- **WS도 동일 메커니즘 + 직접 테스트.** TCP/WS read loop 모두 같은 `OnReceivedFrame`을 호출하고 두 생성자가 대칭으로 `SetFrameErrorHandler`를 등록한다. 문서화로 끝내지 않고 실제 WS read path 테스트로 `DisconnectReasonWSPacketParse`를 확인했다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- gateway 부착 TCP read path에서 malformed raw frame이 parse-error disconnect로 이어지는지 확인한다.
|
||||
- gateway worker가 listener/request/pending/write queue 상태를 소유하지 않는지 확인한다.
|
||||
- 기존 gateway reorder와 decode-error reorder advance 테스트가 새 error propagation 모델에서도 의미 있게 유지되는지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
### REVIEW_REVIEW_API-1 중간 검증
|
||||
```
|
||||
$ cd go && go test -count=1 ./...
|
||||
ok git.toki-labs.com/toki/proto-socket/go 0.043s
|
||||
? git.toki-labs.com/toki/proto-socket/go/crosstest/dart_go_client [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/crosstest/kotlin_go_client [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/crosstest/python_go_client [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/crosstest/typescript_go_client [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/examples/tcp_echo [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/examples/ws_echo [no test files]
|
||||
? git.toki-labs.com/toki/proto-socket/go/packets [no test files]
|
||||
ok git.toki-labs.com/toki/proto-socket/go/test 8.194s
|
||||
|
||||
$ cd go && go test -race ./...
|
||||
ok git.toki-labs.com/toki/proto-socket/go 1.050s
|
||||
? ... (crosstest/examples/packets: no test files)
|
||||
ok git.toki-labs.com/toki/proto-socket/go/test 9.251s
|
||||
```
|
||||
|
||||
gateway error-path 신규/갱신 테스트(`-race`):
|
||||
```
|
||||
$ go test -count=1 -race -v -run 'GatewayParseError|ReportsDecodeError|TcpReadLoop' ./...
|
||||
=== RUN TestWorkerGatewayReportsDecodeErrorAndAdvances
|
||||
--- PASS: TestWorkerGatewayReportsDecodeErrorAndAdvances (0.00s)
|
||||
ok git.toki-labs.com/toki/proto-socket/go 1.011s
|
||||
=== RUN TestTcpReadLoopRoutesThroughGateway
|
||||
--- PASS: TestTcpReadLoopRoutesThroughGateway (0.00s)
|
||||
=== RUN TestTcpReadLoopWorkerGatewayPreservesOrder
|
||||
--- PASS: TestTcpReadLoopWorkerGatewayPreservesOrder (0.01s)
|
||||
=== RUN TestTcpReadLoopParseErrorDisconnects
|
||||
--- PASS: TestTcpReadLoopParseErrorDisconnects (0.00s)
|
||||
=== RUN TestTcpReadLoopGatewayParseErrorDisconnects
|
||||
--- PASS: TestTcpReadLoopGatewayParseErrorDisconnects (0.00s)
|
||||
=== RUN TestWsReadLoopGatewayParseErrorDisconnects
|
||||
--- PASS: TestWsReadLoopGatewayParseErrorDisconnects (0.00s)
|
||||
PASS
|
||||
ok git.toki-labs.com/toki/proto-socket/go/test 1.031s
|
||||
```
|
||||
|
||||
- `TestTcpReadLoopGatewayParseErrorDisconnects`: gateway 부착 TCP read path에서 malformed frame이 `DisconnectReasonTCPPacketParse`로 disconnect(미보존 시 Reason=Unknown으로 실패).
|
||||
- `TestWsReadLoopGatewayParseErrorDisconnects`: 실제 in-process WS read path에서 malformed binary frame이 `DisconnectReasonWSPacketParse`로 disconnect.
|
||||
- `TestWorkerGatewayReportsDecodeErrorAndAdvances`: decode error가 `onError`로 1회 전달되고 이후 good frame은 순서대로 sink에 도달(reorder window stall 없음).
|
||||
- 기존 wiring/순서 보존/inline parse-error 테스트 계속 통과.
|
||||
|
||||
### 최종 검증
|
||||
```
|
||||
$ bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all
|
||||
```
|
||||
|
||||
- 결과: 전 항목 PASS. Proto 동기화 PASS, 동일언어 5/5 PASS, 언어 PASS 매트릭스 5x7 전부 PASS, 크로스테스트 30방향 전부 expected 일치 PASS.
|
||||
- 결과 기록 파일: `agent-test/runs/20260602-022853-proto-socket-full-matrix.md`
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: PASS
|
||||
- 차원별 평가:
|
||||
- correctness: Pass
|
||||
- completeness: Pass
|
||||
- test coverage: Pass
|
||||
- API contract: Pass
|
||||
- code quality: Pass
|
||||
- plan deviation: Pass
|
||||
- verification trust: Pass
|
||||
- 발견된 문제: 없음
|
||||
- 다음 단계: PASS 종결로 `complete.log` 작성 후 task directory를 archive로 이동한다.
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# Complete - m-inbound-queue-ordering/05_go_gateway
|
||||
|
||||
## 완료 일시
|
||||
|
||||
2026-06-02
|
||||
|
||||
## 요약
|
||||
|
||||
Go worker gateway가 실제 TCP/WS read path에 연결되고 gateway decode error도 기존 parse-error disconnect semantics로 전파됨을 3회 리뷰 루프 끝에 PASS로 확인했다.
|
||||
|
||||
## 루프 이력
|
||||
|
||||
| Plan | Review | Verdict | 메모 |
|
||||
|------|--------|---------|------|
|
||||
| `plan_cloud_G08_0.log` | `code_review_cloud_G08_0.log` | FAIL | 실제 TCP/WS read loop가 gateway를 우회했고, 테스트가 `OnReceivedFrame` 직접 호출만 검증했다. |
|
||||
| `plan_cloud_G08_1.log` | `code_review_cloud_G08_1.log` | FAIL | gateway 부착 경로의 malformed frame이 parse-error disconnect로 이어지지 않았다. |
|
||||
| `plan_cloud_G08_2.log` | `code_review_cloud_G08_2.log` | PASS | gateway decode error propagation과 TCP/WS parse-error disconnect 회귀 테스트까지 확인했다. |
|
||||
|
||||
## 구현/정리 내용
|
||||
|
||||
- Go `Communicator`에 inbound worker gateway, raw frame `seq` reorder, frame error handler를 추가했다.
|
||||
- TCP/WS read loop가 raw PacketBase frame을 `OnReceivedFrame`으로 전달해 gateway 부착/미부착 경로를 공유하게 했다.
|
||||
- gateway decode error가 transport-specific parse-error disconnect로 전파되도록 `SetFrameErrorHandler`를 연결했다.
|
||||
- worker gateway reorder, decode error advance, 실제 TCP/WS read-path gateway 동작 테스트를 추가했다.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- `cd go && go test -count=1 ./...` - PASS; Go package 전체 통과.
|
||||
- `cd go && go test -race ./...` - PASS; Go package/test race detector 통과.
|
||||
- `go test -count=1 -race -v -run 'GatewayParseError|ReportsDecodeError|TcpReadLoop' ./...` - PASS; gateway error-path 신규/갱신 테스트 통과.
|
||||
- `bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all` - PASS; 결과 기록 `agent-test/runs/20260602-022853-proto-socket-full-matrix.md`.
|
||||
|
||||
## Roadmap Completion
|
||||
|
||||
- Milestone: `agent-roadmap/milestones/inbound-queue-ordering.md`
|
||||
- Completed task ids:
|
||||
- `go-gateway`: PASS; evidence=`plan_cloud_G08_2.log`, `code_review_cloud_G08_2.log`; verification=`agent-test/runs/20260602-022853-proto-socket-full-matrix.md`
|
||||
- Not completed task ids: 없음
|
||||
|
||||
## 잔여 Nit
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
<!-- task=m-inbound-queue-ordering/05_go_gateway plan=1 tag=REVIEW_API -->
|
||||
|
||||
# Go Worker Gateway Follow-up 계획
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
이 계획은 `code_review_cloud_G08_0.log`의 Required 이슈만 해결한다. 구현 후 `CODE_REVIEW-cloud-G08.md`의 구현 에이전트 소유 섹션에 실제 변경 내용과 검증 stdout/stderr를 채운 뒤 active 파일을 그대로 두고 리뷰를 요청한다. 최종 판정, 로그화, `complete.log`, archive 이동은 code-review 전용이다.
|
||||
|
||||
## 배경
|
||||
|
||||
초기 구현은 `Communicator.OnReceivedFrame`과 worker gateway 자체는 추가했지만, 실제 TCP/WS read loop는 여전히 raw `PacketBase` bytes를 직접 `proto.Unmarshal`하고 `OnReceivedData`로 전달한다. 그 결과 `EnableInboundGateway`를 호출해도 실연결 수신 경로는 gateway를 우회한다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
|
||||
구현 중 user-only blocker가 있으면 active `CODE_REVIEW-cloud-G08.md`의 `사용자 리뷰 요청` 섹션에 결정 필요 항목과 실행한 명령 출력을 기록한다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/milestones/inbound-queue-ordering.md`
|
||||
- Task ids:
|
||||
- `go-gateway`: Go goroutine worker pool gateway 후보를 적용한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- `agent-task/m-inbound-queue-ordering/05_go_gateway/plan_cloud_G08_0.log`
|
||||
- `agent-task/m-inbound-queue-ordering/05_go_gateway/code_review_cloud_G08_0.log`
|
||||
- `go/communicator.go`
|
||||
- `go/inbound_gateway.go`
|
||||
- `go/tcp_client.go`
|
||||
- `go/ws_client.go`
|
||||
- `go/test/communicator_test.go`
|
||||
|
||||
### 테스트 환경 규칙
|
||||
|
||||
- Go 변경 필수 검증은 `cd go && go test -count=1 ./...`.
|
||||
- concurrency/gateway 보완이므로 `cd go && go test -race ./...`를 포함한다.
|
||||
- 최종 검증은 전체 matrix `bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all`.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
- Go transport read path와 gateway tests만 다룬다.
|
||||
- proto schema, wire format, pending/listener/request/write queue 소유 모델은 변경하지 않는다.
|
||||
- parse 실패 시 기존 TCP/WS disconnect reason이 사라지지 않게 한다.
|
||||
|
||||
### 빌드 등급
|
||||
|
||||
- build/review: `cloud-G08`. 이유: 실제 transport read loop와 gateway worker pool 연결, parse error semantics, race 검증이 포함된다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] TCP/WS read loop가 gateway 부착 시 raw frame을 gateway 경로로 전달하고, gateway 미부착 시 기존 inline decode 동작을 유지한다.
|
||||
- [ ] raw frame decode 실패 시 기존 TCP/WS parse error disconnect semantics가 유지되도록 error 처리 경계를 보완한다.
|
||||
- [ ] 실제 TCP 또는 WS read path에서 `EnableInboundGateway`가 dispatch 순서를 보존하는 테스트를 추가한다.
|
||||
- [ ] gateway read path 테스트가 `OnReceivedFrame` 직접 호출만으로 통과하지 않고 transport read loop 연결 누락을 잡을 수 있게 구성한다.
|
||||
- [ ] 중간 검증으로 `cd go && go test -count=1 ./... && go test -race ./...`를 실행하고 실제 stdout/stderr를 기록한다.
|
||||
- [ ] 최종 검증으로 `bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all`을 실행하고 결과 기록 파일을 남긴다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
### [REVIEW_API-1] 실제 transport read path gateway 연결
|
||||
|
||||
#### 문제
|
||||
|
||||
- `TcpClient.readLoop`와 `WsClient.readLoop`가 gateway 부착 여부와 무관하게 직접 protobuf decode 후 `OnReceivedData`를 호출한다.
|
||||
- 따라서 gateway는 단위 helper 경로에서만 동작하고 실제 TCP/WS 연결에서는 적용되지 않는다.
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
- raw frame 처리용 communicator API를 정리해 gateway가 있으면 worker pool로 보내고, 없으면 inline decode 후 coordinator로 전달한다.
|
||||
- read loop가 이 raw frame API를 호출하도록 연결한다.
|
||||
- 기존 read loop의 parse error disconnect reason은 보존한다. 필요하면 raw frame API가 error를 반환하도록 바꾸거나, read loop에서 inline decode와 gateway decode 경계를 명확히 분리한다.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `go/communicator.go`
|
||||
- [ ] `go/inbound_gateway.go`
|
||||
- [ ] `go/tcp_client.go`
|
||||
- [ ] `go/ws_client.go`
|
||||
- [ ] `go/test/communicator_test.go`
|
||||
- [ ] 필요 시 `go/inbound_gateway_test.go`
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
- `net.Pipe` 또는 기존 테스트 helper를 사용해 실제 `TcpClient.readLoop`에 raw PacketBase frame을 넣고, `EnableInboundGateway` 부착 후 listener dispatch 순서가 입력 순서와 일치하는지 확인한다.
|
||||
- 필요하면 WS read path도 동일 방식으로 검증한다. 최소 하나의 실제 transport read loop는 반드시 포함한다.
|
||||
- malformed frame이 들어왔을 때 기존 parse error disconnect 동작이 유지되는지 확인한다.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
cd go && go test -count=1 ./... && go test -race ./...
|
||||
```
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
|------|------|
|
||||
| `go/communicator.go` | REVIEW_API-1 |
|
||||
| `go/inbound_gateway.go` | REVIEW_API-1 |
|
||||
| `go/tcp_client.go` | REVIEW_API-1 |
|
||||
| `go/ws_client.go` | REVIEW_API-1 |
|
||||
| `go/test/communicator_test.go` | REVIEW_API-1 |
|
||||
| `go/inbound_gateway_test.go` | REVIEW_API-1 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all
|
||||
```
|
||||
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
<!-- task=m-inbound-queue-ordering/05_go_gateway plan=2 tag=REVIEW_REVIEW_API -->
|
||||
|
||||
# Go Gateway Parse Error Follow-up 계획
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
이 계획은 `code_review_cloud_G08_1.log`의 Required 이슈만 해결한다. 구현 후 `CODE_REVIEW-cloud-G08.md`의 구현 에이전트 소유 섹션에 실제 변경 내용과 검증 stdout/stderr를 채운 뒤 active 파일을 그대로 두고 리뷰를 요청한다. 최종 판정, 로그화, `complete.log`, archive 이동은 code-review 전용이다.
|
||||
|
||||
## 배경
|
||||
|
||||
두 번째 루프에서 TCP/WS read loop는 gateway raw-frame 경로로 연결되었다. 하지만 gateway 부착 시 malformed raw frame은 worker decode error로 처리된 뒤 조용히 drop되어, 기존 TCP/WS parse-error disconnect semantics가 사라진다. follow-up은 이 semantics 보존과 테스트 보강만 다룬다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
|
||||
구현 중 user-only blocker가 있으면 active `CODE_REVIEW-cloud-G08.md`의 `사용자 리뷰 요청` 섹션에 결정 필요 항목과 실행한 명령 출력을 기록한다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/milestones/inbound-queue-ordering.md`
|
||||
- Task ids:
|
||||
- `go-gateway`: Go goroutine worker pool gateway 후보를 적용한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- `agent-task/m-inbound-queue-ordering/05_go_gateway/code_review_cloud_G08_1.log`
|
||||
- `go/communicator.go`
|
||||
- `go/inbound_gateway.go`
|
||||
- `go/tcp_client.go`
|
||||
- `go/ws_client.go`
|
||||
- `go/test/communicator_test.go`
|
||||
|
||||
### 테스트 환경 규칙
|
||||
|
||||
- Go 변경 필수 검증은 `cd go && go test -count=1 ./...`.
|
||||
- concurrency/gateway error 경계 보완이므로 `cd go && go test -race ./...`를 포함한다.
|
||||
- 최종 검증은 전체 matrix `bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all`.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
- gateway decode error propagation과 TCP/WS parse-error disconnect tests만 다룬다.
|
||||
- worker pool reorder, raw frame wiring, proto schema, wire format, listener/request dispatch 정책은 바꾸지 않는다.
|
||||
|
||||
### 빌드 등급
|
||||
|
||||
- build/review: `cloud-G08`. 이유: gateway worker goroutine의 error propagation과 transport close semantics가 포함된다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] gateway 부착 read path에서 raw frame decode 실패가 기존 TCP/WS parse-error disconnect semantics로 이어지도록 error propagation 경계를 보완한다.
|
||||
- [ ] gateway worker는 stateful dispatch를 소유하지 않고, decode error 전달도 transport/communicator teardown 책임 경계를 침범하지 않게 구성한다.
|
||||
- [ ] TCP gateway 부착 read path에서 malformed frame이 `DisconnectReasonTCPPacketParse`로 disconnect되는 테스트를 추가한다.
|
||||
- [ ] WS gateway 부착 read path의 malformed frame semantics를 테스트하거나, WS를 생략한다면 TCP와 같은 `OnReceivedFrame` error path를 공유한다는 근거를 구현 기록에 남긴다.
|
||||
- [ ] 기존 gateway 순서 보존, worker decode error reorder advance, read loop wiring 테스트가 계속 통과하는지 확인한다.
|
||||
- [ ] 중간 검증으로 `cd go && go test -count=1 ./... && go test -race ./...`를 실행하고 실제 stdout/stderr를 기록한다.
|
||||
- [ ] 최종 검증으로 `bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all`을 실행하고 결과 기록 파일을 남긴다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
### [REVIEW_REVIEW_API-1] gateway decode error semantics 보존
|
||||
|
||||
#### 문제
|
||||
|
||||
- gateway 부착 시 `OnReceivedFrame`은 nil을 반환하고 worker가 malformed frame을 drop하므로 TCP/WS parse-error disconnect가 발생하지 않는다.
|
||||
- 현재 parse-error 테스트는 gateway 미부착 경로만 다뤄 이 회귀를 잡지 못한다.
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
- `WorkerGateway`가 decode error를 sink/error callback으로 전달하거나, `Communicator`가 gateway decode error를 받아 transport별 parse-error handler를 실행할 수 있게 작은 error propagation 경계를 추가한다.
|
||||
- 또는 gateway submit 전에 raw frame validation을 수행하되, 성공 경로의 worker decode/reorder 이점을 과도하게 훼손하지 않는 이유를 구현 기록에 남긴다.
|
||||
- 어떤 방식을 선택하든 gateway는 listener/request/pending/write queue 상태를 소유하지 않는다.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `go/communicator.go`
|
||||
- [ ] `go/inbound_gateway.go`
|
||||
- [ ] `go/tcp_client.go`
|
||||
- [ ] `go/ws_client.go`
|
||||
- [ ] `go/test/communicator_test.go`
|
||||
- [ ] 필요 시 `go/inbound_gateway_test.go`
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
- gateway 부착 TCP read loop에 malformed PacketBase bytes를 넣고 `DisconnectReasonTCPPacketParse`가 기록되는지 확인한다.
|
||||
- 기존 `TestWorkerGatewaySkipsDecodeErrors`의 의도(한 frame decode 실패가 reorder window를 막지 않음)를 새 error propagation 모델에 맞게 유지 또는 갱신한다.
|
||||
- WS도 직접 테스트할 수 있으면 malformed binary frame + gateway 부착에서 `DisconnectReasonWSPacketParse`를 확인한다.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
cd go && go test -count=1 ./... && go test -race ./...
|
||||
```
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
|------|------|
|
||||
| `go/communicator.go` | REVIEW_REVIEW_API-1 |
|
||||
| `go/inbound_gateway.go` | REVIEW_REVIEW_API-1 |
|
||||
| `go/tcp_client.go` | REVIEW_REVIEW_API-1 |
|
||||
| `go/ws_client.go` | REVIEW_REVIEW_API-1 |
|
||||
| `go/test/communicator_test.go` | REVIEW_REVIEW_API-1 |
|
||||
| `go/inbound_gateway_test.go` | REVIEW_REVIEW_API-1 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all
|
||||
```
|
||||
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
<!-- task=m-inbound-queue-ordering/05_go_gateway plan=0 tag=API -->
|
||||
|
||||
# Code Review Reference - API
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST]** 구현 후 이 파일의 구현 에이전트 소유 섹션을 실제 내용과 검증 출력으로 채우고 active 파일을 그대로 둔 채 리뷰를 요청한다. 최종화는 code-review 전용이다.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-06-02
|
||||
task=m-inbound-queue-ordering/05_go_gateway, plan=0, tag=API
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/milestones/inbound-queue-ordering.md`
|
||||
- Task ids:
|
||||
- `go-gateway`: Go goroutine worker pool gateway 후보를 적용한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [API-1] Go gateway worker pool | [ ] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] Go worker gateway 타입과 내부 `seq` 포함 input/output 구조를 추가한다.
|
||||
- [ ] worker pool은 raw bytes decode/순수 전처리만 수행하고, pending/listener/request/write queue 상태를 소유하지 않는다.
|
||||
- [ ] gateway 결과를 `seq` 기준으로 reorder한 뒤 기존 `EnqueueInbound`/receive coordinator로 전달한다.
|
||||
- [ ] Go tests에 대량 packet decode, out-of-order worker completion, race-safe dispatch 순서 검증을 추가한다.
|
||||
- [ ] 중간 검증으로 `cd go && go test -count=1 ./... && go test -race ./...`를 실행하고 실제 stdout/stderr를 기록한다.
|
||||
- [ ] 최종 검증으로 `bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all`을 실행하고 결과 기록 파일을 남긴다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [ ] active review/plan을 `.log`로 아카이브한다.
|
||||
- [ ] PASS이면 `complete.log` 작성 후 task directory를 archive로 이동한다.
|
||||
- [ ] PASS이면 runtime completion metadata를 보고하고 roadmap은 직접 수정하지 않는다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 기록한다._
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 기록한다._
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- gateway가 stateful dispatcher 책임을 갖지 않는지 확인한다.
|
||||
- `go test -race ./...` 결과가 실제 출력인지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
### API-1 중간 검증
|
||||
```
|
||||
$ cd go && go test -count=1 ./... && go test -race ./...
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```
|
||||
$ bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all
|
||||
```
|
||||
|
|
@ -45,8 +45,10 @@ type inboundItem struct {
|
|||
type Communicator struct {
|
||||
mu sync.RWMutex
|
||||
nonce atomic.Int32
|
||||
frameSeq atomic.Int64
|
||||
isAlive atomic.Bool
|
||||
drainOnClose atomic.Bool
|
||||
gateway InboundGateway
|
||||
parserMap ParserMap
|
||||
handlers map[string][]func(proto.Message)
|
||||
reqHandlers map[string]func(proto.Message, int32)
|
||||
|
|
@ -58,6 +60,7 @@ type Communicator struct {
|
|||
closeOnce sync.Once
|
||||
transport Transport
|
||||
writeErrHandler func(error)
|
||||
frameErrHandler func(error)
|
||||
}
|
||||
|
||||
func TypeNameOf(m proto.Message) string {
|
||||
|
|
@ -102,6 +105,26 @@ func (c *Communicator) SetWriteErrorHandler(fn func(error)) {
|
|||
c.writeErrHandler = fn
|
||||
}
|
||||
|
||||
// SetFrameErrorHandler registers a handler invoked when an inbound gateway fails
|
||||
// to decode a raw frame. Transports use it to apply their parse-error disconnect
|
||||
// semantics for the gateway path, mirroring SetWriteErrorHandler for the write
|
||||
// path. Without a gateway, OnReceivedFrame surfaces decode errors via its return
|
||||
// value instead and this handler is unused.
|
||||
func (c *Communicator) SetFrameErrorHandler(fn func(error)) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.frameErrHandler = fn
|
||||
}
|
||||
|
||||
func (c *Communicator) onFrameError(err error) {
|
||||
c.mu.RLock()
|
||||
handler := c.frameErrHandler
|
||||
c.mu.RUnlock()
|
||||
if handler != nil {
|
||||
handler(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Communicator) nextNonce() int32 {
|
||||
for {
|
||||
current := c.nonce.Load()
|
||||
|
|
@ -127,6 +150,7 @@ func (c *Communicator) shutdown() {
|
|||
func (c *Communicator) ForceShutdown() {
|
||||
c.drainOnClose.Store(false)
|
||||
c.shutdown()
|
||||
c.closeGateway()
|
||||
if c.receiveDone != nil {
|
||||
<-c.receiveDone
|
||||
}
|
||||
|
|
@ -136,6 +160,7 @@ func (c *Communicator) ForceShutdown() {
|
|||
func (c *Communicator) Close() error {
|
||||
c.drainOnClose.Store(true)
|
||||
c.shutdown()
|
||||
c.closeGateway()
|
||||
if c.receiveDone != nil {
|
||||
<-c.receiveDone
|
||||
}
|
||||
|
|
@ -293,6 +318,80 @@ func (c *Communicator) OnReceivedData(typeName string, data []byte, incomingNonc
|
|||
c.EnqueueInbound(typeName, data, incomingNonce, responseNonce)
|
||||
}
|
||||
|
||||
// EnableInboundGateway attaches a goroutine worker-pool gateway in front of the
|
||||
// receive coordinator. Raw frames submitted via OnReceivedFrame are decoded by
|
||||
// the pool and reordered by an internal seq before reaching OnReceivedData, so
|
||||
// the coordinator keeps FIFO dispatch and sole ownership of stateful handling.
|
||||
//
|
||||
// The gateway performs pure PacketBase envelope decode only; it never touches
|
||||
// the pending-request map, listeners, or the write queue. Opt-in: without it
|
||||
// OnReceivedFrame decodes inline on the calling goroutine.
|
||||
func (c *Communicator) EnableInboundGateway(workers, queueSize int) {
|
||||
gateway := NewWorkerGateway(workers, queueSize,
|
||||
func(f DecodedFrame) {
|
||||
c.OnReceivedData(f.TypeName, f.Data, f.IncomingNonce, f.ResponseNonce)
|
||||
},
|
||||
func(err error) {
|
||||
// Surface gateway decode errors to the transport's frame error
|
||||
// handler so the parse-error disconnect semantics match the inline
|
||||
// path. The gateway never tears down the transport itself.
|
||||
c.onFrameError(err)
|
||||
},
|
||||
)
|
||||
c.AttachInboundGateway(gateway)
|
||||
}
|
||||
|
||||
// AttachInboundGateway installs a custom InboundGateway in front of the receive
|
||||
// coordinator. The gateway's sink must forward decoded frames to OnReceivedData
|
||||
// to preserve coordinator ownership. Use EnableInboundGateway for the default
|
||||
// worker-pool gateway.
|
||||
func (c *Communicator) AttachInboundGateway(gateway InboundGateway) {
|
||||
c.mu.Lock()
|
||||
c.gateway = gateway
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// OnReceivedFrame ingests a raw PacketBase frame from a transport read loop.
|
||||
//
|
||||
// When a gateway is attached the frame is submitted with an internal seq for
|
||||
// off-coordinator decode + reorder and nil is returned; a frame that fails to
|
||||
// decode is reported asynchronously, in seq order, to the frame error handler
|
||||
// registered via SetFrameErrorHandler so the transport can apply the same
|
||||
// parse-error disconnect semantics as the inline path. The gateway never tears
|
||||
// down the transport itself.
|
||||
//
|
||||
// Without a gateway the frame is decoded inline on the calling goroutine and
|
||||
// forwarded to OnReceivedData; a decode error is returned so the transport can
|
||||
// apply its existing parse-error disconnect semantics.
|
||||
func (c *Communicator) OnReceivedFrame(raw []byte) error {
|
||||
if !c.IsAlive() {
|
||||
return nil
|
||||
}
|
||||
c.mu.RLock()
|
||||
gateway := c.gateway
|
||||
c.mu.RUnlock()
|
||||
if gateway != nil {
|
||||
gateway.Submit(InboundFrame{Seq: c.frameSeq.Add(1), Bytes: raw})
|
||||
return nil
|
||||
}
|
||||
base, err := decodePacketBase(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.OnReceivedData(base.GetTypeName(), base.GetData(), base.GetNonce(), base.GetResponseNonce())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Communicator) closeGateway() {
|
||||
c.mu.Lock()
|
||||
gateway := c.gateway
|
||||
c.gateway = nil
|
||||
c.mu.Unlock()
|
||||
if gateway != nil {
|
||||
gateway.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Communicator) EnqueueInbound(typeName string, data []byte, incomingNonce, responseNonce int32) {
|
||||
if !c.IsAlive() {
|
||||
return
|
||||
|
|
|
|||
228
go/inbound_gateway.go
Normal file
228
go/inbound_gateway.go
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
package proto_socket
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"git.toki-labs.com/toki/proto-socket/go/packets"
|
||||
)
|
||||
|
||||
// InboundFrame is a raw inbound frame queued for gateway preprocessing.
|
||||
//
|
||||
// Seq is an internal monotonically increasing sequence assigned by the receive
|
||||
// side before submitting to the gateway. It is used only to restore input order
|
||||
// after parallel decoding and never touches the wire format or any public
|
||||
// protocol field.
|
||||
type InboundFrame struct {
|
||||
Seq int64
|
||||
Bytes []byte
|
||||
}
|
||||
|
||||
// DecodedFrame is the result of decoding a PacketBase envelope inside a gateway.
|
||||
//
|
||||
// It carries the same envelope fields that Communicator.OnReceivedData expects,
|
||||
// plus the originating Seq so results can be reordered before dispatch.
|
||||
type DecodedFrame struct {
|
||||
Seq int64
|
||||
TypeName string
|
||||
Data []byte
|
||||
IncomingNonce int32
|
||||
ResponseNonce int32
|
||||
}
|
||||
|
||||
// InboundGateway performs only pure raw-bytes work (PacketBase envelope decode /
|
||||
// lightweight preprocessing) off the receive coordinator. It must NOT own
|
||||
// stateful dispatch, the pending-request map, listeners, or the outbound write
|
||||
// queue — those stay on the receive coordinator. Decoded results are delivered
|
||||
// to a sink in input Seq order so the coordinator can dispatch deterministically.
|
||||
type InboundGateway interface {
|
||||
// Submit hands a raw frame to the gateway. Fire-and-forget; the decoded
|
||||
// result is later delivered to the sink in Seq order.
|
||||
Submit(frame InboundFrame)
|
||||
// Close releases gateway resources and stops delivering results.
|
||||
Close()
|
||||
}
|
||||
|
||||
// decodeFunc decodes a raw frame into a PacketBase envelope. Pure: no shared
|
||||
// state, safe to run concurrently across workers.
|
||||
type decodeFunc func([]byte) (*packets.PacketBase, error)
|
||||
|
||||
func decodePacketBase(raw []byte) (*packets.PacketBase, error) {
|
||||
base := &packets.PacketBase{}
|
||||
if err := proto.Unmarshal(raw, base); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// workerResult is a per-frame decode outcome carried through the reorder buffer.
|
||||
// err frames still advance the sequence so a single bad frame cannot stall the
|
||||
// reorder window.
|
||||
type workerResult struct {
|
||||
seq int64
|
||||
frame DecodedFrame
|
||||
err error
|
||||
}
|
||||
|
||||
// frameReorderBuffer restores input order from results that may arrive out of
|
||||
// order. Sequences are expected to be contiguous starting at 1. A result is
|
||||
// buffered until every earlier sequence has been released, then it and any
|
||||
// now-contiguous successors are returned in order. It is not safe for concurrent
|
||||
// use; the WorkerGateway confines it to the single collector goroutine.
|
||||
type frameReorderBuffer struct {
|
||||
nextSeq int64
|
||||
pending map[int64]workerResult
|
||||
}
|
||||
|
||||
func newFrameReorderBuffer() *frameReorderBuffer {
|
||||
return &frameReorderBuffer{nextSeq: 1, pending: make(map[int64]workerResult)}
|
||||
}
|
||||
|
||||
// release buffers res and returns the results now releasable in seq order.
|
||||
func (b *frameReorderBuffer) release(res workerResult) []workerResult {
|
||||
if res.seq < b.nextSeq {
|
||||
// Already released (or duplicate); ignore to avoid emitting twice.
|
||||
return nil
|
||||
}
|
||||
b.pending[res.seq] = res
|
||||
var ready []workerResult
|
||||
for {
|
||||
r, ok := b.pending[b.nextSeq]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
ready = append(ready, r)
|
||||
delete(b.pending, b.nextSeq)
|
||||
b.nextSeq++
|
||||
}
|
||||
return ready
|
||||
}
|
||||
|
||||
// WorkerGateway is a goroutine worker-pool gateway. A bounded jobs channel feeds
|
||||
// N workers that decode PacketBase envelopes in parallel; a single collector
|
||||
// goroutine reorders the results by Seq and delivers them to the sink in input
|
||||
// order. Because the collector is the only goroutine that calls the sink, sink
|
||||
// invocations are serialized and ordered, keeping stateful dispatch on the
|
||||
// receive coordinator.
|
||||
type WorkerGateway struct {
|
||||
jobs chan InboundFrame
|
||||
results chan workerResult
|
||||
done chan struct{}
|
||||
collectorDone chan struct{}
|
||||
wg sync.WaitGroup
|
||||
closeOnce sync.Once
|
||||
parse decodeFunc
|
||||
sink func(DecodedFrame)
|
||||
onError func(error)
|
||||
}
|
||||
|
||||
// NewWorkerGateway creates a worker-pool gateway that decodes PacketBase frames
|
||||
// and delivers them to sink in Seq order. A frame that fails to decode is not
|
||||
// sent to sink; instead onError is invoked with the decode error in Seq order so
|
||||
// the transport can apply its existing parse-error disconnect semantics. The
|
||||
// gateway itself never tears down the transport — onError keeps that decision on
|
||||
// the caller. onError may be nil to ignore decode errors.
|
||||
//
|
||||
// workers <= 0 defaults to NumCPU and queueSize <= 0 defaults to 64. The bounded
|
||||
// jobs channel provides backpressure: Submit blocks once it is full until a
|
||||
// worker frees a slot.
|
||||
func NewWorkerGateway(workers, queueSize int, sink func(DecodedFrame), onError func(error)) *WorkerGateway {
|
||||
return newWorkerGateway(workers, queueSize, decodePacketBase, sink, onError)
|
||||
}
|
||||
|
||||
func newWorkerGateway(workers, queueSize int, parse decodeFunc, sink func(DecodedFrame), onError func(error)) *WorkerGateway {
|
||||
if workers <= 0 {
|
||||
workers = runtime.NumCPU()
|
||||
}
|
||||
if queueSize <= 0 {
|
||||
queueSize = 64
|
||||
}
|
||||
g := &WorkerGateway{
|
||||
jobs: make(chan InboundFrame, queueSize),
|
||||
results: make(chan workerResult, queueSize),
|
||||
done: make(chan struct{}),
|
||||
collectorDone: make(chan struct{}),
|
||||
parse: parse,
|
||||
sink: sink,
|
||||
onError: onError,
|
||||
}
|
||||
for i := 0; i < workers; i++ {
|
||||
g.wg.Add(1)
|
||||
go g.worker()
|
||||
}
|
||||
go g.collector()
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *WorkerGateway) worker() {
|
||||
defer g.wg.Done()
|
||||
for {
|
||||
select {
|
||||
case frame := <-g.jobs:
|
||||
res := workerResult{seq: frame.Seq}
|
||||
base, err := g.parse(frame.Bytes)
|
||||
if err != nil {
|
||||
res.err = err
|
||||
} else {
|
||||
res.frame = DecodedFrame{
|
||||
Seq: frame.Seq,
|
||||
TypeName: base.GetTypeName(),
|
||||
Data: base.GetData(),
|
||||
IncomingNonce: base.GetNonce(),
|
||||
ResponseNonce: base.GetResponseNonce(),
|
||||
}
|
||||
}
|
||||
select {
|
||||
case g.results <- res:
|
||||
case <-g.done:
|
||||
return
|
||||
}
|
||||
case <-g.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *WorkerGateway) collector() {
|
||||
defer close(g.collectorDone)
|
||||
reorder := newFrameReorderBuffer()
|
||||
for {
|
||||
select {
|
||||
case res := <-g.results:
|
||||
for _, ready := range reorder.release(res) {
|
||||
if ready.err != nil {
|
||||
// Report the decode error in Seq order and keep going so a
|
||||
// single bad frame does not stall later frames. The caller's
|
||||
// onError decides whether to tear down the transport.
|
||||
if g.onError != nil {
|
||||
g.onError(ready.err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
g.sink(ready.frame)
|
||||
}
|
||||
case <-g.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Submit hands a raw frame to the worker pool. It blocks while the bounded jobs
|
||||
// channel is full (backpressure) and returns immediately once the gateway is
|
||||
// closed.
|
||||
func (g *WorkerGateway) Submit(frame InboundFrame) {
|
||||
select {
|
||||
case g.jobs <- frame:
|
||||
case <-g.done:
|
||||
}
|
||||
}
|
||||
|
||||
// Close signals all workers and the collector to stop. It is idempotent and
|
||||
// does not block on in-flight sink calls.
|
||||
func (g *WorkerGateway) Close() {
|
||||
g.closeOnce.Do(func() {
|
||||
close(g.done)
|
||||
})
|
||||
}
|
||||
145
go/inbound_gateway_test.go
Normal file
145
go/inbound_gateway_test.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package proto_socket
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"git.toki-labs.com/toki/proto-socket/go/packets"
|
||||
)
|
||||
|
||||
// TestFrameReorderBufferReleasesInOrder: out-of-order results are buffered and
|
||||
// released only once the contiguous prefix is complete.
|
||||
func TestFrameReorderBufferReleasesInOrder(t *testing.T) {
|
||||
b := newFrameReorderBuffer()
|
||||
|
||||
if got := b.release(workerResult{seq: 3}); len(got) != 0 {
|
||||
t.Fatalf("seq 3 should buffer until 1,2 arrive, released %d", len(got))
|
||||
}
|
||||
if got := b.release(workerResult{seq: 2}); len(got) != 0 {
|
||||
t.Fatalf("seq 2 should still buffer until 1 arrives, released %d", len(got))
|
||||
}
|
||||
got := b.release(workerResult{seq: 1})
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 1,2,3 to flush together, got %d", len(got))
|
||||
}
|
||||
for i, r := range got {
|
||||
if r.seq != int64(i+1) {
|
||||
t.Fatalf("reorder violated: position %d has seq %d", i, r.seq)
|
||||
}
|
||||
}
|
||||
|
||||
// A seq below nextSeq is a duplicate/late arrival and is ignored.
|
||||
if got := b.release(workerResult{seq: 1}); len(got) != 0 {
|
||||
t.Fatalf("late duplicate seq should be ignored, released %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkerGatewayReordersOutOfOrderCompletion: workers that finish out of
|
||||
// order still reach the sink in input seq order. Earlier seqs are made to decode
|
||||
// slower so the pool genuinely completes them last.
|
||||
func TestWorkerGatewayReordersOutOfOrderCompletion(t *testing.T) {
|
||||
const total = 8
|
||||
|
||||
var mu sync.Mutex
|
||||
var order []int32
|
||||
|
||||
parse := func(raw []byte) (*packets.PacketBase, error) {
|
||||
base := &packets.PacketBase{}
|
||||
if err := proto.Unmarshal(raw, base); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Lower nonce (earlier seq) sleeps longer to force reversed completion.
|
||||
delay := time.Duration(total-base.GetNonce()) * 5 * time.Millisecond
|
||||
time.Sleep(delay)
|
||||
return base, nil
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
sink := func(f DecodedFrame) {
|
||||
mu.Lock()
|
||||
order = append(order, f.IncomingNonce)
|
||||
n := len(order)
|
||||
mu.Unlock()
|
||||
if n == total {
|
||||
close(done)
|
||||
}
|
||||
}
|
||||
|
||||
g := newWorkerGateway(4, 16, parse, sink, nil)
|
||||
defer g.Close()
|
||||
|
||||
for seq := int64(1); seq <= total; seq++ {
|
||||
data, err := proto.Marshal(&packets.PacketBase{TypeName: "x", Nonce: int32(seq)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.Submit(InboundFrame{Seq: seq, Bytes: data})
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for gateway results")
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for i, n := range order {
|
||||
if n != int32(i+1) {
|
||||
t.Fatalf("seq ordering not preserved at position %d: %v", i, order)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkerGatewayReportsDecodeErrorAndAdvances: a frame that fails to decode is
|
||||
// reported to onError (not the sink) and does not stall later frames; subsequent
|
||||
// good frames still reach the sink in order.
|
||||
func TestWorkerGatewayReportsDecodeErrorAndAdvances(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var seen []int32
|
||||
var errCount int
|
||||
|
||||
done := make(chan struct{})
|
||||
sink := func(f DecodedFrame) {
|
||||
mu.Lock()
|
||||
seen = append(seen, f.IncomingNonce)
|
||||
n := len(seen)
|
||||
mu.Unlock()
|
||||
if n == 2 {
|
||||
close(done)
|
||||
}
|
||||
}
|
||||
onError := func(error) {
|
||||
mu.Lock()
|
||||
errCount++
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
g := NewWorkerGateway(2, 8, sink, onError)
|
||||
defer g.Close()
|
||||
|
||||
good1, _ := proto.Marshal(&packets.PacketBase{TypeName: "x", Nonce: 1})
|
||||
good2, _ := proto.Marshal(&packets.PacketBase{TypeName: "x", Nonce: 3})
|
||||
|
||||
g.Submit(InboundFrame{Seq: 1, Bytes: good1})
|
||||
g.Submit(InboundFrame{Seq: 2, Bytes: []byte{0xff, 0xff, 0xff}}) // undecodable
|
||||
g.Submit(InboundFrame{Seq: 3, Bytes: good2})
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out; a decode error stalled the reorder window")
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(seen) != 2 || seen[0] != 1 || seen[1] != 3 {
|
||||
t.Fatalf("expected sink to see [1 3] skipping the bad frame, got %v", seen)
|
||||
}
|
||||
if errCount != 1 {
|
||||
t.Fatalf("expected exactly one decode error reported, got %d", errCount)
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,9 @@ func NewTcpClient(conn net.Conn, intervalSec, waitSec int, parserMap ParserMap)
|
|||
c.SetWriteErrorHandler(func(err error) {
|
||||
c.onDisconnected(DisconnectReasonWriteError, err)
|
||||
})
|
||||
c.SetFrameErrorHandler(func(err error) {
|
||||
c.onDisconnected(DisconnectReasonTCPPacketParse, err)
|
||||
})
|
||||
c.AddListener(TypeNameOf(&packets.HeartBeat{}), func(m proto.Message) {
|
||||
c.onHeartBeat()
|
||||
})
|
||||
|
|
@ -80,12 +83,13 @@ func (c *TcpClient) readLoop() {
|
|||
c.onDisconnected(tcpReadDisconnectReason(DisconnectReasonTCPReadPayload, err), err)
|
||||
return
|
||||
}
|
||||
base := &packets.PacketBase{}
|
||||
if err := proto.Unmarshal(packetBytes, base); err != nil {
|
||||
// OnReceivedFrame routes the raw frame through the inbound gateway when
|
||||
// one is attached, or decodes it inline otherwise. An inline decode
|
||||
// error preserves the existing parse-error disconnect semantics.
|
||||
if err := c.OnReceivedFrame(packetBytes); err != nil {
|
||||
c.onDisconnected(DisconnectReasonTCPPacketParse, err)
|
||||
return
|
||||
}
|
||||
c.OnReceivedData(base.GetTypeName(), base.GetData(), base.GetNonce(), base.GetResponseNonce())
|
||||
c.sendHeartBeat()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package proto_socket_test
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
|
@ -304,4 +306,263 @@ func TestWorkerGatewayReorderAndDispatch(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestInboundGatewayPreservesDispatchOrder: with the worker-pool gateway
|
||||
// attached, a burst of raw frames decoded in parallel is still dispatched to the
|
||||
// listener in input order, proving the coordinator keeps FIFO ownership.
|
||||
func TestInboundGatewayPreservesDispatchOrder(t *testing.T) {
|
||||
transport := &fakeTransport{}
|
||||
communicator := toki.NewCommunicator(transport, testParserMap())
|
||||
communicator.EnableInboundGateway(4, 64)
|
||||
defer communicator.Close()
|
||||
|
||||
var mu sync.Mutex
|
||||
var received []int32
|
||||
|
||||
toki.AddListenerTyped[*packets.TestData](communicator, func(m *packets.TestData) {
|
||||
mu.Lock()
|
||||
received = append(received, m.GetIndex())
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
const total = 200
|
||||
typeName := toki.TypeNameOf(&packets.TestData{})
|
||||
for i := int32(1); i <= total; i++ {
|
||||
data, _ := proto.Marshal(&packets.TestData{Index: i})
|
||||
frame, err := proto.Marshal(&packets.PacketBase{TypeName: typeName, Nonce: i, Data: data})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
communicator.OnReceivedFrame(frame)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
mu.Lock()
|
||||
n := len(received)
|
||||
mu.Unlock()
|
||||
if n == total {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(received) != total {
|
||||
t.Fatalf("expected %d messages, got %d", total, len(received))
|
||||
}
|
||||
for i, idx := range received {
|
||||
if idx != int32(i+1) {
|
||||
t.Fatalf("gateway dispatch order violated at position %d: got index %d", i, idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// frameTCP builds a TCP wire frame: 4-byte big-endian length prefix + PacketBase.
|
||||
func frameTCP(t *testing.T, base *packets.PacketBase) []byte {
|
||||
t.Helper()
|
||||
b, err := proto.Marshal(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := make([]byte, 4+len(b))
|
||||
binary.BigEndian.PutUint32(out[:4], uint32(len(b)))
|
||||
copy(out[4:], b)
|
||||
return out
|
||||
}
|
||||
|
||||
// countingGateway is a test InboundGateway that records every Submit and then
|
||||
// forwards the decoded frame to the supplied sink. It lets a transport read-path
|
||||
// test prove the read loop actually routes raw frames through the gateway rather
|
||||
// than decoding inline and bypassing it.
|
||||
type countingGateway struct {
|
||||
onSubmit func(toki.InboundFrame)
|
||||
}
|
||||
|
||||
func (g *countingGateway) Submit(frame toki.InboundFrame) { g.onSubmit(frame) }
|
||||
func (g *countingGateway) Close() {}
|
||||
|
||||
// TestTcpReadLoopRoutesThroughGateway: a real TcpClient.readLoop must hand raw
|
||||
// frames to the attached gateway. The counting gateway fails the test if the
|
||||
// read loop decodes inline and bypasses the gateway (the original wiring gap).
|
||||
func TestTcpReadLoopRoutesThroughGateway(t *testing.T) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
// intervalSec=0 disables heartbeats, so the client never writes to the pipe.
|
||||
client := toki.NewTcpClient(clientConn, 0, 0, testParserMap())
|
||||
defer client.Close()
|
||||
|
||||
var mu sync.Mutex
|
||||
var received []int32
|
||||
submitted := 0
|
||||
|
||||
gw := &countingGateway{onSubmit: func(frame toki.InboundFrame) {
|
||||
mu.Lock()
|
||||
submitted++
|
||||
mu.Unlock()
|
||||
base := &packets.PacketBase{}
|
||||
if err := proto.Unmarshal(frame.Bytes, base); err != nil {
|
||||
return
|
||||
}
|
||||
client.OnReceivedData(base.GetTypeName(), base.GetData(), base.GetNonce(), base.GetResponseNonce())
|
||||
}}
|
||||
client.AttachInboundGateway(gw)
|
||||
|
||||
toki.AddListenerTyped[*packets.TestData](&client.Communicator, func(m *packets.TestData) {
|
||||
mu.Lock()
|
||||
received = append(received, m.GetIndex())
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
const total = 20
|
||||
typeName := toki.TypeNameOf(&packets.TestData{})
|
||||
frames := make([][]byte, 0, total)
|
||||
for i := int32(1); i <= total; i++ {
|
||||
data, err := proto.Marshal(&packets.TestData{Index: i})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
frames = append(frames, frameTCP(t, &packets.PacketBase{TypeName: typeName, Nonce: i, Data: data}))
|
||||
}
|
||||
go func() {
|
||||
for _, f := range frames {
|
||||
if _, err := serverConn.Write(f); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
mu.Lock()
|
||||
n := len(received)
|
||||
mu.Unlock()
|
||||
if n == total {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if submitted != total {
|
||||
t.Fatalf("read loop bypassed gateway: submitted=%d, want %d", submitted, total)
|
||||
}
|
||||
if len(received) != total {
|
||||
t.Fatalf("expected %d messages, got %d", total, len(received))
|
||||
}
|
||||
for i, idx := range received {
|
||||
if idx != int32(i+1) {
|
||||
t.Fatalf("dispatch order violated at position %d: got index %d", i, idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTcpReadLoopWorkerGatewayPreservesOrder: a real TcpClient.readLoop with the
|
||||
// production worker-pool gateway (EnableInboundGateway) still dispatches frames
|
||||
// to the listener in input order despite parallel decode.
|
||||
func TestTcpReadLoopWorkerGatewayPreservesOrder(t *testing.T) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
client := toki.NewTcpClient(clientConn, 0, 0, testParserMap())
|
||||
defer client.Close()
|
||||
client.EnableInboundGateway(4, 64)
|
||||
|
||||
var mu sync.Mutex
|
||||
var received []int32
|
||||
toki.AddListenerTyped[*packets.TestData](&client.Communicator, func(m *packets.TestData) {
|
||||
mu.Lock()
|
||||
received = append(received, m.GetIndex())
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
const total = 100
|
||||
typeName := toki.TypeNameOf(&packets.TestData{})
|
||||
frames := make([][]byte, 0, total)
|
||||
for i := int32(1); i <= total; i++ {
|
||||
data, err := proto.Marshal(&packets.TestData{Index: i})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
frames = append(frames, frameTCP(t, &packets.PacketBase{TypeName: typeName, Nonce: i, Data: data}))
|
||||
}
|
||||
go func() {
|
||||
for _, f := range frames {
|
||||
if _, err := serverConn.Write(f); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
mu.Lock()
|
||||
n := len(received)
|
||||
mu.Unlock()
|
||||
if n == total {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(received) != total {
|
||||
t.Fatalf("expected %d messages, got %d", total, len(received))
|
||||
}
|
||||
for i, idx := range received {
|
||||
if idx != int32(i+1) {
|
||||
t.Fatalf("dispatch order violated at position %d: got index %d", i, idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTcpReadLoopParseErrorDisconnects: with no gateway, a malformed frame in
|
||||
// the real read loop must still trigger the parse-error disconnect.
|
||||
func TestTcpReadLoopParseErrorDisconnects(t *testing.T) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
client := toki.NewTcpClient(clientConn, 0, 0, testParserMap())
|
||||
defer client.Close()
|
||||
|
||||
// length=3 prefix followed by bytes that are not valid protobuf wire format.
|
||||
bad := []byte{0, 0, 0, 3, 0xff, 0xff, 0xff}
|
||||
go func() { _, _ = serverConn.Write(bad) }()
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if r := client.DisconnectInfo().Reason; r != toki.DisconnectReasonUnknown {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
info := client.DisconnectInfo()
|
||||
if info.Reason != toki.DisconnectReasonTCPPacketParse {
|
||||
t.Fatalf("expected %s disconnect, got %q", toki.DisconnectReasonTCPPacketParse, info.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTcpReadLoopGatewayParseErrorDisconnects: with the gateway attached, a
|
||||
// malformed frame decoded off the read loop must still surface as the same
|
||||
// parse-error disconnect via the gateway error path.
|
||||
func TestTcpReadLoopGatewayParseErrorDisconnects(t *testing.T) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
client := toki.NewTcpClient(clientConn, 0, 0, testParserMap())
|
||||
defer client.Close()
|
||||
client.EnableInboundGateway(2, 16)
|
||||
|
||||
// length=3 prefix followed by bytes that are not valid protobuf wire format.
|
||||
bad := []byte{0, 0, 0, 3, 0xff, 0xff, 0xff}
|
||||
go func() { _, _ = serverConn.Write(bad) }()
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if r := client.DisconnectInfo().Reason; r != toki.DisconnectReasonUnknown {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
info := client.DisconnectInfo()
|
||||
if info.Reason != toki.DisconnectReasonTCPPacketParse {
|
||||
t.Fatalf("expected %s disconnect via gateway error path, got %q", toki.DisconnectReasonTCPPacketParse, info.Reason)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -303,3 +303,59 @@ func TestWsOriginPatterns(t *testing.T) {
|
|||
t.Fatal("expected default server to reject cross-origin, but it succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWsReadLoopGatewayParseErrorDisconnects: a malformed binary frame received
|
||||
// by a gateway-attached WsClient.readLoop must disconnect with
|
||||
// DisconnectReasonWSPacketParse, matching the inline parse-error semantics.
|
||||
func TestWsReadLoopGatewayParseErrorDisconnects(t *testing.T) {
|
||||
port := freePort(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var mu sync.Mutex
|
||||
var srvClient *toki.WsClient
|
||||
server := toki.NewWsServer("127.0.0.1", port, "/", func(conn *websocket.Conn) *toki.WsClient {
|
||||
c := toki.NewWsClient(conn, 0, 0, testParserMap())
|
||||
c.EnableInboundGateway(2, 16)
|
||||
mu.Lock()
|
||||
srvClient = c
|
||||
mu.Unlock()
|
||||
return c
|
||||
})
|
||||
if err := server.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer server.Stop()
|
||||
|
||||
// Raw client conn so we can send bytes that are not valid PacketBase.
|
||||
conn, _, err := websocket.Dial(ctx, fmt.Sprintf("ws://127.0.0.1:%d/", port), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "")
|
||||
|
||||
if err := conn.Write(ctx, websocket.MessageBinary, []byte{0xff, 0xff, 0xff}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
mu.Lock()
|
||||
c := srvClient
|
||||
mu.Unlock()
|
||||
if c != nil && c.DisconnectInfo().Reason != toki.DisconnectReasonUnknown {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
c := srvClient
|
||||
mu.Unlock()
|
||||
if c == nil {
|
||||
t.Fatal("server never accepted the client connection")
|
||||
}
|
||||
if info := c.DisconnectInfo(); info.Reason != toki.DisconnectReasonWSPacketParse {
|
||||
t.Fatalf("expected %s disconnect via gateway error path, got %q", toki.DisconnectReasonWSPacketParse, info.Reason)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ func NewWsClient(conn *websocket.Conn, intervalSec, waitSec int, parserMap Parse
|
|||
c.SetWriteErrorHandler(func(err error) {
|
||||
c.onDisconnected(DisconnectReasonWriteError, err)
|
||||
})
|
||||
c.SetFrameErrorHandler(func(err error) {
|
||||
c.onDisconnected(DisconnectReasonWSPacketParse, err)
|
||||
})
|
||||
c.AddListener(TypeNameOf(&packets.HeartBeat{}), func(m proto.Message) {
|
||||
c.onHeartBeat()
|
||||
})
|
||||
|
|
@ -95,12 +98,13 @@ func (c *WsClient) readLoop() {
|
|||
if msgType != websocket.MessageBinary {
|
||||
continue
|
||||
}
|
||||
base := &packets.PacketBase{}
|
||||
if err := proto.Unmarshal(b, base); err != nil {
|
||||
// OnReceivedFrame routes the raw frame through the inbound gateway when
|
||||
// one is attached, or decodes it inline otherwise. An inline decode
|
||||
// error preserves the existing parse-error disconnect semantics.
|
||||
if err := c.OnReceivedFrame(b); err != nil {
|
||||
c.onDisconnected(DisconnectReasonWSPacketParse, err)
|
||||
return
|
||||
}
|
||||
c.OnReceivedData(base.GetTypeName(), base.GetData(), base.GetNonce(), base.GetResponseNonce())
|
||||
c.sendHeartBeat()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue