Files
tech-log-frontend/docs/reviews/adapters/02-realtime-and-browser-rpc.md
T
DongHyeonkaandClaude Opus 5 4bff9ca151 chore: sync the frontend template from 4dc033c to 8157ad4
The product was materialized from the template at `4dc033c` and has stayed
on it through 43 template commits, so it was missing all three rounds of
adapter remediation — including files it never had, such as the shared
`abortable-operation` primitive and the `exact-snapshot` decoder that
later fixes are written against. Taking only the newest round was not
possible for that reason: the delta is coherent only as a whole.

The product had not touched `src/adapters` at all since materialization,
so the 140-file delta applied with a three-way merge and no conflicts.
`package.json` was the single overlap and merged cleanly: the product owns
`name`, the template contributed `check:adapter-inventory`,
`check:remediation-ledger` and the image-resolve-signal type fixture.
All 24 product-owned files — README, index.html, CI workflow, i18n
catalog, home page, generated schemas, evidence scripts, component and
visual snapshots — are byte-identical to `main`.

`template.lock.json` now pins the synced revision and tree.

Verified in this repository, not inherited from the template: six type
projects, lint, nine gates (adapter inventory, remediation ledger,
registries, diagnostics, realtime boundaries, architecture, browser
file/storage boundaries, optional recipes, documentation), the production
build, and 2,054 of 2,073 tests. The 19 failures are all in
`tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing
sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template
records; four suites that failed once under parallel load pass in
isolation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:04:58 +09:00

460 lines
40 KiB
Markdown

# Realtime / Browser RPC adapter 구현 리뷰
- 리뷰 기준: `4dc033c` (2026-08-13, Asia/Seoul)
- 구현 범위: `src/adapters/realtime/**`, `src/adapters/browser-rpc/**`
- 추적 범위: 대응 contracts, application ports, bootstrap 조립, unit/boundary tests, architecture docs
- 방식: 코드 리뷰만 수행했다. 이 문서 외 구현 파일은 수정하지 않았다.
- 결론: **Critical 0, High 4, Medium 3**이다. R-01~R-06은 코드상 확정된 lifecycle/immutability/resource 문제이고, R-07은 문서에도 미완료라고 명시된 production promotion blocker다. 두 runtime 모두 현재 `AVAILABLE_NOT_COMPOSED`이므로 production traffic 사고로 과장하지 않는다.
## 1. 21/21 파일 inventory와 책임
아래 경로는 모두 저장소 루트 기준 full path이며, 범위의 구현 파일 21개를 모두 읽었다.
| # | full path | 책임 | 주요 의존성 / downstream | 판정 |
|---:|---|---|---|---|
| 1 | `src/adapters/browser-rpc/browser-rpc-runtime.ts` | operation을 unary/server-stream application port로 bind하고 request schema/encoder, deadline/retry, transport, response schema/mapper, generation fence를 순서대로 집행 | `application/ports/browser-rpc`, `ClockPort`, Browser RPC contract, schema/mapper registry, `transport.ts`, `AppFailure` | R-01, R-04, R-06, R-07 |
| 2 | `src/adapters/browser-rpc/index.ts` | Browser RPC public adapter export surface | runtime, transport, unavailable adapter | 새 lease/install type export 필요 |
| 3 | `src/adapters/browser-rpc/transport.ts` | provider-neutral unary/stream transport result와 runtime identity 계약 | `src/contracts/browser-rpc.ts` | R-01, R-07 |
| 4 | `src/adapters/browser-rpc/unavailable-browser-rpc-transport.ts` | 선택되지 않은 runtime의 명시적 fail-closed Null Object | `transport.ts` | 유지; 새 stream lease shape만 맞춤 |
| 5 | `src/adapters/realtime/event-codec.ts` | raw JSON byte/shape/registry/schema 검증, immutable DTO와 semantic fingerprint 생성 | realtime contracts, schema registry, JSON scanner, result codec | 유지 |
| 6 | `src/adapters/realtime/event-consumer.ts` | SSE/WS cursor 규칙을 codec 결과와 결합하고 common stream coordinator outcome으로 투영 | realtime ports/contracts, event codec, stream coordinator | 유지 |
| 7 | `src/adapters/realtime/index.ts` | common realtime adapter public export surface | codec, consumer, reconnect, handoff, stream, sub-index | R-02/R-03 lifecycle type export 필요 |
| 8 | `src/adapters/realtime/json-member-scanner.ts` | `JSON.parse` 전 duplicate member와 structure budget을 비재귀적으로 검사 | 독립 utility | 유지 |
| 9 | `src/adapters/realtime/live-poll-handoff-coordinator.ts` | LIVE/POLL 단일 effect writer, generation fence, quiescence/checkpoint, probe buffer와 전환 | `ClockPort`, realtime result/ports | R-03 |
| 10 | `src/adapters/realtime/polling/bounded-poll-coordinator.ts` | visible/online finite lease, single-flight poll, retry hint, response/apply deadline, non-cooperative task drain | bounded polling policy, `ClockPort`, realtime result | 유지 |
| 11 | `src/adapters/realtime/polling/index.ts` | polling public exports | bounded poll coordinator | 유지 |
| 12 | `src/adapters/realtime/reconnect-coordinator.ts` | 단일 reconnect owner, online gate, retry budget, session close authority, exact recovery proof, post-abort DRAINING | reconnect policy, `ClockPort`, realtime ports/result | 유지 |
| 13 | `src/adapters/realtime/reconnect-policy.ts` | immutable reconnect policy, full jitter, elapsed budget, Retry-After 계산/검증 | 독립 policy | 유지 |
| 14 | `src/adapters/realtime/result.ts` | hostile/mutable collaborator result를 exact own-data snapshot으로 canonicalize | realtime ports/contracts | 유지; R-04의 기준 패턴 |
| 15 | `src/adapters/realtime/sse/fetch-sse-connection.ts` | fixed same-origin fetch-stream SSE, response/media/open gate, read/event deadline, cursor rule, bounded reader cancel | `ClockPort`, event authority/result, parser, reconnect policy | 유지; R-02 common drain과 함께 검증 |
| 16 | `src/adapters/realtime/sse/index.ts` | SSE public exports | fetch connection, parser | 유지 |
| 17 | `src/adapters/realtime/sse/sse-parser.ts` | strict incremental UTF-8 SSE parser, BOM/line ending/id/retry/event buffer ceiling | realtime contracts/result | 유지 |
| 18 | `src/adapters/realtime/stream-coordinator.ts` | per-stream sequential effect, dedupe/order, recovery, checkpoint/barrier, scope generation fence | event authority port, realtime contracts, mapper registry, codec/result | R-02 |
| 19 | `src/adapters/realtime/websocket/index.ts` | WebSocket connection/protocol public exports | connection, protocol | 유지 |
| 20 | `src/adapters/realtime/websocket/websocket-connection.ts` | 한 physical WS의 handshake/subscription/tombstone/FIFO/heartbeat/apply gate/recovery close | `ClockPort`, realtime contracts/result, WS protocol | 유지; R-02 upstream timeout과 함께 검증 |
| 21 | `src/adapters/realtime/websocket/websocket-protocol.ts` | exact closed JSON frame decode/encode, duplicate key/structure/sequence/frame byte 검증 | realtime contracts, JSON scanner | R-05 |
## 2. 추적한 contracts, ports, bootstrap, tests, docs
| 계층 | 읽은 파일과 근거 | 대조 결과 |
|---|---|---|
| Realtime contracts | `src/contracts/realtime-streams.ts`, `src/contracts/realtime-events.ts` | registry가 stream/event/recovery/queue ceiling을 닫고 cursor/sequence/scope 문법을 소유한다. adapter가 이를 우회하지 않는다. |
| Realtime ports | `src/application/ports/realtime/shared.ts:1-66`, `src/application/ports/realtime/event-authority.ts:15-202`, `src/application/ports/realtime/index.ts:1-31` | native error/payload/cursor 없는 closed result, exact recovery checkpoint identity, effect/recovery commit authority를 확인했다. R-02 lifecycle inspection 확장이 필요하다. |
| Browser RPC contract | `src/contracts/browser-rpc.ts:66-179,256-469,472-591` | wire/profile/operation join과 hard limit은 풍부하지만 validate-only mutable binding이다(R-04). `maxBufferedBytes`는 선언/검증만 된다(R-07). |
| Browser RPC port | `src/application/ports/browser-rpc/browser-rpc.ts:4-35`, `src/application/ports/browser-rpc/index.ts` | application에는 typed unary/stream Result만 보이고 generated type/frame/endpoint는 노출되지 않는다. 변경 불필요. |
| Clock / Result | `src/application/ports/clock-port.ts`, `src/adapters/platform/system-clock.ts`, `src/application/result.ts`, `src/contracts/errors.ts` | injected clock/fence failure도 port Result 의미로 닫아야 한다(R-06). |
| Bootstrap | `src/bootstrap/optional-runtime-host.ts:21-29,65-70,87-92,142-150` | `realtime: null`, health `UNAVAILABLE`, 제품 contribution 전 미조립은 의도다. Browser RPC 조립도 없다. 미조립 자체는 결함이 아니다. |
| Boundary gates | `scripts/check-realtime-boundaries.ts`, `scripts/lib/realtime-boundaries.ts`, `scripts/check-realtime-boundary-fixtures.ts`, `scripts/test-realtime-runtime-removal.ts`; `tests/fixtures/realtime-boundaries/allowed/**`, `forbidden/**` | native realtime API 소유권과 unselected composition을 정적 검사한다. Browser RPC에는 아직 같은 별도 boundary gate가 없다. |
| Realtime docs | `docs/architecture/decisions/VD-28-realtime-events-web-push-and-bounded-polling.md`, `docs/architecture/realtime-events-web-push-and-bounded-polling.md`, `docs/architecture/optional-adapter-recipes.md` | fixed endpoint, exact barrier, single writer, overflow fail-close, bounded cleanup/DRAINING, 미조립 상태를 코드와 대조했다. |
| Browser RPC docs | `docs/architecture/protobuf-browser-transport-and-rest-gateway.md`, `docs/architecture/decisions/VD-27-grpc-web-unary-and-server-stream.md`, `docs/architecture/decisions/VD-29-connect-web-and-browser-protobuf-runtime.md` | common lifecycle만 구현됐고 concrete framing/raw-byte/provider/browser conformance는 pending이라고 명시한다. |
대조한 16개 테스트 파일:
- `tests/unit/browser-rpc/browser-rpc-contract.test.ts`
- `tests/unit/browser-rpc/browser-rpc-runtime.test.ts`
- `tests/unit/realtime/bounded-poll-coordinator.test.ts`
- `tests/unit/realtime/bounded-polling-policy.test.ts`
- `tests/unit/realtime/event-codec.test.ts`
- `tests/unit/realtime/event-consumer.test.ts`
- `tests/unit/realtime/fetch-sse-connection.test.ts`
- `tests/unit/realtime/live-poll-handoff-coordinator.test.ts`
- `tests/unit/realtime/realtime-reconnect-coordinator.test.ts`
- `tests/unit/realtime/realtime-reconnect-policy.test.ts`
- `tests/unit/realtime/realtime-stream-registry.test.ts`
- `tests/unit/realtime/result.test.ts`
- `tests/unit/realtime/sse-parser.test.ts`
- `tests/unit/realtime/stream-coordinator.test.ts`
- `tests/unit/realtime/websocket-connection.test.ts`
- `tests/unit/realtime/websocket-protocol.test.ts`
## 3. 분류
### 확정 결함
| ID | 심각도 | 확신도 | 요약 |
|---|---|---|---|
| R-01 | High | High | Browser RPC server-stream 종료가 non-cooperative iterator에서 무기한 멈춘다. |
| R-02 | High | High | common stream coordinator가 non-cooperative effect/recovery 하나로 영구 wedge된다. |
| R-03 | High | High | LIVE↔POLL overflow fail-close가 active lease를 잃어 이후 close가 거짓 성공한다. |
| R-04 | High | High | Browser RPC bindings는 validate-then-use TOCTOU이며 exact immutable install이 아니다. |
| R-05 | Medium | High | WS frame byte ceiling 전에 입력 전체 UTF-8 copy를 추가 할당한다. |
| R-06 | Medium | High | Browser RPC clock/fence 예외가 Result 경계를 탈출하고 cleanup을 건너뛴다. |
### 미조립 단계 promotion blocker / 선택 개선
| ID | 심각도 | 확신도 | 요약 |
|---|---|---|---|
| R-07 | Medium, promotion blocker | High | `maxBufferedBytes`의 concrete transport 집행 및 provider/browser conformance가 아직 없다. 문서에도 pending으로 명시되어 현재 common runtime bug로 세지 않는다. |
## 4. 확정 결함 상세
### R-01 — Browser RPC server-stream 종료가 non-cooperative iterator에서 무기한 멈춘다
- 심각도: **High**
- 확신도: **High**
- 근거:
- `src/adapters/browser-rpc/transport.ts:53-61`은 stream을 `AsyncIterable` 하나로 표현한다. 명시적 `cancel`/`waitClosed`/cleanup bound가 없다.
- `src/adapters/browser-rpc/browser-rpc-runtime.ts:488-512``iterator.next()`를 deadline과 race하지만, timeout 뒤 원래 `next()` task는 남을 수 있다.
- `src/adapters/browser-rpc/browser-rpc-runtime.ts:630-640``finally`에서 `await iterator.return()`을 deadline 없이 기다린다. pending `next()`가 signal을 무시하면 async generator의 queued `return()`도 완료되지 않는다.
- 영향: caller abort, idle/total timeout, response limit, consumer `break` 뒤 application iterator completion이 무기한 pending이다. 외부 abort listener 수명도 `finally` 완료 전까지 닫히지 않는다. timeout Result를 선택했어도 iterator가 끝나지 않아 total deadline 의미가 깨진다.
- 기존 증거와 gap: `tests/unit/browser-rpc/browser-rpc-runtime.test.ts:343-369`은 cooperative generator가 abort를 보고 `finally`로 끝나는 경우만 확인한다. `docs/architecture/protobuf-browser-transport-and-rest-gateway.md:381-399`는 reader cancel/release, bounded consumer queue, terminal envelope, EOF non-success를 요구한다.
- 적용 패턴: **Explicit Stream Lease + structured concurrency + retained DRAINING task**. 암묵적인 `AsyncIterable.return()`에 transport lifecycle authority를 숨기지 않는다.
- 결정:
1. app-facing generator는 idle/total/limit/caller abort 후 cleanup bound 안에 끝난다.
2. commit/admission generation은 즉시 fence한다.
3. underlying task가 bound 안에 끝나지 않으면 transport lease는 `DRAINING`에 남고 실제 `waitClosed()` settlement까지 추적한다.
4. `return()`/`waitClosed()` rejection은 이미 선택한 application failure를 덮지 않는다.
### R-02 — common stream coordinator가 non-cooperative authority 하나로 영구 wedge된다
- 심각도: **High**
- 확신도: **High**
- 근거:
- `src/adapters/realtime/stream-coordinator.ts:231-247`은 event를 `state.tail`에 직렬 연결한다.
- `src/adapters/realtime/stream-coordinator.ts:373-408`은 effect authority를 직접 `await`한다. AbortSignal을 무시하는 Promise에 deadline/drain state가 없다.
- recovery는 `src/adapters/realtime/stream-coordinator.ts:497-529`에서 기존 tail을 기다리고 `:543-560`에서 recovery authority를 다시 무기한 기다린다.
- `close():809-834`는 controller만 abort하고 즉시 `void`로 끝나 실제 settlement/DRAINING을 나타내지 않는다.
- 영향: WS `maxApplyMs`(`websocket-connection.ts:1060-1080`)나 SSE event timeout(`fetch-sse-connection.ts:321-355`)은 transport caller만 끝낸다. common tail은 pending이라 새 generation event와 queue-overflow recovery까지 영구 대기한다. generation fence는 late commit을 막지만 liveness/resource convergence는 보장하지 않는다.
- 기존 증거와 gap:
- `tests/unit/realtime/stream-coordinator.test.ts:383-466`의 in-flight effect는 결국 resolve되고 `:791-821`의 non-cooperative recovery도 테스트 끝에서 settle한다. never-settling authority와 bounded close는 없다.
- `VD-28...md:218-224,250-256,437-446`은 terminal/idempotent close와 bound를 넘긴 task가 실제 settle할 때까지 `DRAINING`을 유지하도록 정한다.
- 적용 패턴: **per-stream State Machine + Task Lease Registry + generation capability**.
- 결정:
1. freshness와 별도로 lifecycle `OPEN | DRAINING | CLOSED`를 둔다.
2. effect/recovery deadline에 commit capability를 영구 false로 만들고 abort한다.
3. caller에는 `IDLE_TIMEOUT` (`operation: APPLY | RECOVER`, non-retryable)을 bounded하게 반환하고 실제 task는 retain한다.
4. DRAINING 중 새 event/recovery를 허용하지 않는다. actual settle 뒤 `STALE`에서 authoritative recovery를 요구하거나 close 요청이면 `CLOSED`로 간다.
5. `close()``Promise<RealtimeResult<void>>`로 bounded quiescence 결과를 반환한다.
### R-03 — LIVE↔POLL overflow 뒤 active writer reference를 잃는다
- 심각도: **High**
- 확신도: **High**
- 근거:
- `src/adapters/realtime/live-poll-handoff-coordinator.ts:274-286`은 active tail overflow 시 `failClosed()`를 호출한다.
- `failClosed():774-788`은 controller를 abort한 뒤 `active = null`로 지우지만 해당 lease/tail을 retired set에 보존하지 않는다.
- `performClose():672-700`은 현재 active/probe/quiescing/transitionCandidate만 모으므로 이미 버린 non-cooperative active writer를 기다리지 않고 success할 수 있다.
- 영향: 256건/4MiB overflow로 generation 전체를 닫았지만 effect는 계속 실행 중이고 lifecycle owner가 추적하지 않는다. teardown success가 quiescence를 뜻하지 않아 새 runtime과 old task가 겹칠 수 있다. `isCurrent()`는 commit만 fence한다.
- 기존 증거와 gap:
- `tests/unit/realtime/live-poll-handoff-coordinator.test.ts:166-215`는 non-cooperative overflow를 만들지만 이후 `close()`를 호출하지 않는다.
- `:466-493`의 close test는 reference를 잃기 전 active writer만 다룬다.
- ADR `VD-28...md:422-427,443-446`은 overflow full-generation fail-close와 actual settlement까지 DRAINING을 요구한다.
- 적용 패턴: **Retired Lease Registry + two-phase close**.
- 결정: `failClosed()`는 모든 lease를 abort하고 `retiredWriters`에 옮겨 admission을 닫는다. `close()`는 current+retired를 dedupe해 bounded하게 기다리고, timeout에는 `IDLE_TIMEOUT/CLOSE`를 반환하되 마지막 tail settlement까지 DRAINING을 유지한다.
### R-04 — Browser RPC bindings가 validate-then-use TOCTOU이다
- 심각도: **High**
- 확신도: **High**
- 근거:
- `src/contracts/browser-rpc.ts:256-285``define*`는 shallow spread/freeze만 하고 exact own key/data descriptor를 검사하지 않는다. extra/accessor property가 남는다.
- `validateBrowserRpcContractBindings():330-469`은 원본 registry/row를 읽어 `true`만 반환하며 installed snapshot을 만들지 않는다.
- `src/adapters/browser-rpc/browser-rpc-runtime.ts:102-128`은 factory에서 검증한 뒤 `bind()` 때 원본 `dependencies.*`를 다시 읽는다.
- `src/adapters/browser-rpc/browser-rpc-runtime.ts:644-687`도 validation용 runtime identity만 복사하며 operations/profiles/schema/mappers/encoders/transports 원본을 계속 사용한다.
- 영향: TypeScript `Readonly`는 runtime 보호가 아니다. factory 이후 mutation으로 replay policy, attempt/deadline, byte ceiling, mapper/transport selection을 validation과 다르게 만들 수 있다. operation/profile 객체의 extra property도 transport가 해석할 수 있다.
- 기존 증거와 gap: `tests/unit/browser-rpc/browser-rpc-contract.test.ts:173-202`는 raw invalid row를 재검증하지만 검증 후 mutation, accessor non-invocation, extra/symbol key 거절은 없다. realtime `result.test.ts:15-151`과 reconnect policy tests에는 exact descriptor snapshot 패턴이 이미 있다.
- 적용 패턴: **Parse/Validate/Install anti-corruption layer + immutable exact registry snapshot**.
- 결정:
1. factory 시작 시 registry own descriptors를 한 번 캡처하고 null-prototype exact map으로 복사/freeze한다.
2. operation/profile/encoder/schema/mapper/transport row를 허용 key의 own data property로 snapshot한다. getter, extra, symbol, revoked proxy는 composition-time `TypeError`다.
3. runtime과 transport call은 installed snapshot만 사용한다.
4. parse/map/encode/invoke function identity는 snapshot하되 row/registry를 재독하지 않는다.
### R-05 — WS byte cap 전에 전체 UTF-8 copy를 할당한다
- 심각도: **Medium**
- 확신도: **High**
- 근거: `src/adapters/realtime/websocket/websocket-protocol.ts:215-228`은 먼저 `utf8ByteLength(input)`을 호출하고 `:469-470``new TextEncoder().encode(input)`으로 전체 크기의 두 번째 buffer를 만든다.
- 영향: hostile/buggy server가 큰 text frame을 보냈을 때 negotiated cap으로 즉시 거절하지 못하고 cap 확인 전에 전체 UTF-8 copy를 추가 할당한다. browser가 원본 string을 materialize했다는 사실과 adapter의 추가 peak allocation은 별개다.
- 기존 증거와 gap: `tests/unit/realtime/websocket-protocol.test.ts:130-166`은 결과 코드와 multibyte bytes는 확인하지만 pre-allocation reject는 확인하지 않는다. `event-codec.ts:102-115``raw.length > maxBytes` 선검사를 이미 사용한다.
- 적용 패턴: **admission before allocation + bounded incremental accounting**.
- 결정: `input.length > maxFrameBytes`를 먼저 거절한다. 남은 입력은 allocation 없는 code-point loop로 UTF-8 bytes를 누적해 초과 즉시 중단하며 lone surrogate는 `TextEncoder`와 동일하게 replacement 3 bytes로 센다.
### R-06 — Browser RPC collaborator exception이 Result 경계를 탈출한다
- 심각도: **Medium**
- 확신도: **High**
- 근거:
- unary `src/adapters/browser-rpc/browser-rpc-runtime.ts:188-191,219-221,307-323``clock.now()`를 safe wrapper 없이 호출한다.
- `mapResponse():778-786,835-845``generationFence.isCurrent()``clock.now()`도 throw를 잡지 않는다.
- `raceWithin():1094-1120`은 abort listener를 붙인 뒤 `clock.sleep()` synchronous throw 또는 race 예외를 감싸는 `finally`가 없다.
- unary 전체에 outer `try/finally`가 없어 `linked.cleanup():198`은 정상 `finish()` 경로에서만 보장된다.
- 영향: application port가 `Promise<Result<...>>`/`AsyncIterable<Result<...>>` 대신 native rejection을 노출한다. clock/scope owner 실패 시 listener/timer cleanup과 observation도 빠질 수 있다.
- 기존 증거와 gap: standard `systemClock`, 정상 fence, generation change는 테스트하지만 throwing clock/fence와 listener balance는 없다. bounded poll/reconnect는 `safeNow`, `safeIsCurrent`, `finally` cleanup을 이미 사용한다.
- 적용 패턴: **Result boundary guard + RAII-style finally**.
- 결정: clock failure는 `SERVER_FAILURE/RPC_RUNTIME_DEPENDENCY_FAILED`, capture/isCurrent 실패는 fail-closed `SCOPE_GENERATION_CHANGED/RPC_SCOPE_GENERATION_UNAVAILABLE`로 canonicalize한다. linked listener/timer는 단일 outer `finally`에서 정확히 한 번 해제한다.
## 5. 미조립/promotion blocker
### R-07 — `maxBufferedBytes` 집행 증거가 없다
- 심각도: **Medium, production promotion blocker**
- 확신도: **High**
- 확정 사실:
- `src/contracts/browser-rpc.ts:114-120,519-528``maxBufferedBytes`를 선언/검증한다.
- common runtime은 `src/adapters/browser-rpc/browser-rpc-runtime.ts:587-593`에서 yielded message count/per-message/aggregate만 센다.
- `src/adapters/browser-rpc/transport.ts:58-60`의 bare `AsyncIterable`에는 buffer admission/inspection contract가 없다.
- 문서로 확인한 현재 상태: `docs/architecture/protobuf-browser-transport-and-rest-gateway.md:42-61,363-366,381-399`는 selected transport/raw-byte cap/provider-browser conformance가 pending이라고 명시한다. 따라서 common runtime이 wire framing/internal buffer를 직접 집행하지 않는 것 자체는 현재 결함이 아니다.
- promotion 위험: callback/stock client가 consumer보다 빨리 frame을 쌓으면 common runtime이 item을 받기 전에 heap cap이 깨질 수 있다. `maxTotalResponseBytes`는 aggregate이고 `maxBufferedBytes`와 다른 backpressure 축이다.
- 적용 패턴: **transport conformance contract + enqueue-time backpressure admission**.
- 결정: concrete Connect/gRPC-Web transport가 enqueue 전에 `operation.maxBufferedBytes`, raw/decompressed ceiling을 집행하고 overflow 시 lease cancel + `RESPONSE_BODY_LIMIT`을 낸다는 conformance suite를 통과하기 전 bootstrap/product traffic을 금지한다. common runtime의 message/aggregate guard는 second line으로 유지한다.
## 6. 상태머신, protocol, framing, backpressure와 cleanup 결정
| 축 | 명시 결정 | 이유 |
|---|---|---|
| Common stream state | freshness `UNKNOWN/CURRENT/STALE/RESYNCING`와 lifecycle `OPEN/DRAINING/CLOSED`를 직교 축으로 둔다. timeout/abort 뒤 actual task가 남으면 DRAINING이다. | commit fence와 resource settlement는 다른 사실이다(R-02). |
| Reconnect | 기존 `IDLE/RUNNING/DRAINING/CLOSED`, 단일 retry owner, full jitter, exact bounded server hint, exact branded recovery proof를 유지한다. offline에는 retry timer를 두지 않고 protocol 자동 downgrade를 금지한다. | 구현/ADR/test가 일치한다. |
| Poll lease | 기존 single-flight `IDLE/RUNNING/DRAINING/CLOSED`, visible+online finite lease, one-request HTTP retry owner를 유지한다. | non-cooperative execute/apply를 이미 fence+track한다. |
| LIVE↔POLL handoff | Poll은 probe 동안 유일 authoritative writer다. old writer fence→abort→quiesce→checkpoint→buffer drain 뒤 LIVE를 활성화한다. overflow는 generation terminal이며 retired lease actual settlement까지 DRAINING이다. | silent overlap/lost update 방지(R-03). |
| SSE framing | strict UTF-8, blank-line terminated SSE, incomplete EOF discard, CURSOR일 때만 explicit `id`, exact status/media/same-origin 규칙을 유지한다. | tests/docs와 일치한다. |
| WS framing | text JSON + exact frame keys + duplicate-member/structure/uint64 검증을 유지한다. byte cap은 allocation 전에 집행한다. malformed/overflow는 whole generation close + snapshot recovery다. | classic WS에는 receive pause가 없고 delta drop은 안전하지 않다. |
| Browser RPC framing | common runtime은 logical message/terminal/failure만 받는다. Connect 5-byte envelope/EndStream과 gRPC-Web trailer authority는 concrete transport가 각각 소유하며 서로 추론/혼합하지 않는다. EOF alone은 success가 아니다. | provider-neutral layer와 wire semantics를 분리한다. |
| Backpressure | WS inbound/outbound와 `bufferedAmount`, handoff queues, Browser RPC transport buffer를 count+bytes로 admission한다. cap 초과는 silent drop/자동 상향 없이 terminal close/failure다. | state-bearing delta의 부분 유실은 복구 없이는 안전하지 않다. |
| Cancel/timer/listener | listener를 얻은 scope의 `finally`에서 제거하고 모든 sleep timer controller를 abort한다. non-cooperative task의 caller wait만 bounded하고 reference는 actual settlement까지 retain한다. | bounded response와 resource convergence를 함께 만족한다. |
| Error semantics | `QUEUE_OVERFLOW`=admission/backpressure와 recovery 필요, `IDLE_TIMEOUT`=handler/quiescence cleanup deadline, `APPLY_FAILED`=authority reject/throw/invalid result, `PROVIDER_UNAVAILABLE`=clock/host dependency 실패, `PROTOCOL_MISMATCH`=shape/framing 위반, `SCOPE_FENCED`=old generation. raw/native 원인은 노출하지 않는다. | retry/rollback/운영 대응을 원인별로 닫는다. |
## 7. 제안 인터페이스와 정확한 파일 작업
### 7.1 새/변경 interface signature
```ts
// src/adapters/browser-rpc/transport.ts
export type BrowserRpcStreamCancelReason =
| "CALLER_ABORT"
| "IDLE_TIMEOUT"
| "TOTAL_DEADLINE"
| "LIMIT_EXCEEDED"
| "CONTRACT_FAILURE"
| "CONSUMER_CLOSED";
export type BrowserRpcTransportStream = Readonly<{
frames: AsyncIterable<BrowserRpcStreamFrame>;
cancel(reason: BrowserRpcStreamCancelReason): void;
waitClosed(): Promise<void>;
}>;
export type BrowserRpcTransport = BrowserRpcRuntimeBindingIdentity & Readonly<{
invokeUnary?(call: BrowserRpcTransportCall): Promise<BrowserRpcUnaryTransportResult>;
openServerStream?(call: BrowserRpcTransportCall): BrowserRpcTransportStream;
}>;
```
```ts
// src/contracts/browser-rpc.ts
export type InstalledBrowserRpcContractBindings = Readonly<{
operations: Readonly<Record<string, BrowserRpcOperationV3>>;
profiles: Readonly<Record<string, BrowserRpcProviderProfile>>;
schemaCodecs: Readonly<Record<string, RuntimeSchemaCodec>>;
mappers: Readonly<Record<string, InstalledBoundaryMapper>>;
requestEncoders: Readonly<Record<string, BrowserRpcRequestEncoder>>;
runtimeBindings: Readonly<Record<string, BrowserRpcRuntimeBindingIdentity>>;
}>;
export function installBrowserRpcContractBindings(
bindings: BrowserRpcContractBindings,
): InstalledBrowserRpcContractBindings;
```
```ts
// src/adapters/browser-rpc/browser-rpc-runtime.ts
export type BrowserRpcRuntimeDependencies = Readonly<{
// existing registries/collaborators stay
streamCleanupTimeoutMs?: number; // default 2_000, implementation max 30_000
}>;
```
```ts
// src/application/ports/realtime/event-authority.ts
export type RealtimeStreamLifecycle = "OPEN" | "DRAINING" | "CLOSED";
export type RealtimeStreamInspection = Readonly<{
lifecycle: RealtimeStreamLifecycle;
// existing freshness/queue/dedupe/barrier fields unchanged
}>;
```
```ts
// src/adapters/realtime/stream-coordinator.ts
export type RealtimeStreamTaskLimits = Readonly<{
effectTimeoutMs: number;
recoveryTimeoutMs: number;
drainTimeoutMs: number;
}>;
export type RealtimeStreamCoordinatorDependencies = Readonly<{
// existing dependencies stay
clock?: ClockPort;
taskLimits: RealtimeStreamTaskLimits;
}>;
export type RealtimeStreamCoordinator = Readonly<{
// existing methods stay
close(): Promise<RealtimeResult<void>>;
}>;
```
```ts
// src/adapters/realtime/live-poll-handoff-coordinator.ts
export type LivePollHandoffState =
| "LIVE_ACTIVE"
| "POLL_ACTIVE"
| "LIVE_PROBING"
| "DRAINING"
| "CLOSED";
export type LivePollHandoffInspection = Readonly<{
// existing fields stay
drainingWriters: number;
}>;
```
### 7.2 정확한 생성/수정/삭제/이동 목록
**생성:** 없음. lifecycle/install type은 기존 owner 파일에 둔다. 이 리뷰 문서 `docs/reviews/adapters/02-realtime-and-browser-rpc.md`만 리뷰 산출물로 새로 생성했다.
**수정:**
1. `src/contracts/browser-rpc.ts` — exact descriptor snapshot installer와 installed type.
2. `src/adapters/browser-rpc/transport.ts` — explicit stream lease/cancel/closed receipt.
3. `src/adapters/browser-rpc/browser-rpc-runtime.ts` — installed snapshot만 사용, bounded stream cleanup/DRAINING, safe clock/fence, outer cleanup.
4. `src/adapters/browser-rpc/unavailable-browser-rpc-transport.ts` — unavailable stream을 즉시 closed lease로 반환.
5. `src/adapters/browser-rpc/index.ts` — installed/stream lifecycle types export.
6. `src/application/ports/realtime/event-authority.ts` — stream lifecycle inspection.
7. `src/application/ports/realtime/index.ts``RealtimeStreamLifecycle` export.
8. `src/adapters/realtime/stream-coordinator.ts` — bounded task lease registry, lifecycle state, async close.
9. `src/adapters/realtime/live-poll-handoff-coordinator.ts` — retired writer set과 DRAINING convergence.
10. `src/adapters/realtime/index.ts` — lifecycle/limit types export.
11. `src/adapters/realtime/websocket/websocket-protocol.ts` — allocation-free bounded UTF-8 counter.
12. `tests/unit/browser-rpc/browser-rpc-contract.test.ts` — mutation/accessor/extra-key installer tests.
13. `tests/unit/browser-rpc/browser-rpc-runtime.test.ts` — non-cooperative stream, throwing clock/fence, cleanup balance tests와 fixture lease 전환.
14. `tests/unit/realtime/stream-coordinator.test.ts` — never-settling effect/recovery, DRAINING/async close tests.
15. `tests/unit/realtime/live-poll-handoff-coordinator.test.ts` — overflow 뒤 retired writer close test.
16. `tests/unit/realtime/websocket-protocol.test.ts` — oversize preflight/multibyte/lone-surrogate tests.
17. `docs/architecture/protobuf-browser-transport-and-rest-gateway.md` — stream lease, buffer owner, promotion evidence.
18. `docs/architecture/realtime-events-web-push-and-bounded-polling.md` — common stream/handoff DRAINING와 error semantics.
19. `docs/architecture/decisions/VD-28-realtime-events-web-push-and-bounded-polling.md` — actual-settlement lifecycle amendment.
**삭제:** 없음.
**이동:** 없음.
**의도적으로 변경하지 않음:** `src/bootstrap/optional-runtime-host.ts`는 제품/provider 선택 전 `null/UNAVAILABLE` 유지가 맞다. `src/application/ports/browser-rpc/browser-rpc.ts`의 app-facing API도 변경할 필요가 없다.
## 8. TDD 테스트 계획
먼저 아래 테스트를 실패시키고(red), 최소 구현 후 개별 green, 마지막에 전체 범위를 실행한다.
| 테스트 이름 | 입력/준비 | 기대 결과 |
|---|---|---|
| `bounds_non_cooperative_stream_cancel_and_completes_consumer` | Browser RPC stream의 `next()``waitClosed()`가 signal/cancel을 무시; idle deadline 진행 | caller iterator는 cleanup bound 안에 `REQUEST_TIMEOUT` 후 done; `cancel("IDLE_TIMEOUT")` 1회; lease DRAINING |
| `rejects_new_stream_while_prior_lease_is_draining` | 위 stream actual settlement 전 같은 transport에 두 번째 open | network side effect 없이 `SERVER_FAILURE/RPC_STREAM_DRAINING`; old settle 후 새 open 가능 |
| `consumer_break_cancels_and_bounds_stream_cleanup` | 첫 message 뒤 consumer `break`; close non-cooperative | `cancel("CONSUMER_CLOSED")`; generator return bounded; listener/timer 0 |
| `runtime_snapshots_bindings_before_later_mutation` | factory 뒤 원본 operation retry/deadline/profile/transport map mutation | execute는 installed snapshot만 사용; mutation이 의미 변경 불가 |
| `binding_installer_rejects_extra_and_accessor_keys_without_invoking_them` | operation/profile/registry에 getter, symbol, extra key | getter 호출 0; composition-time `TypeError` |
| `returns_canonical_failure_and_cleans_listener_when_clock_throws` | transport 전/후 `clock.now`/`sleep` synchronous throw; listener-counting signal | rejection 없음; 지정 `AppFailure`; listener/timer 0; observation 1회 |
| `fences_when_generation_fence_throws` | `capture` 또는 `isCurrent` throw | `SCOPE_GENERATION_CHANGED/RPC_SCOPE_GENERATION_UNAVAILABLE`; mapped value 미commit |
| `keeps_stream_draining_until_non_cooperative_effect_actually_settles` | effect Promise never settles; fake clock가 effect/drain deadline 진행 | accept는 bounded `IDLE_TIMEOUT/APPLY`; `isCurrent=false`; DRAINING; 새 effect 0 |
| `bounds_non_cooperative_recovery_and_rejects_late_checkpoint` | recovery가 timeout 뒤 늦게 success checkpoint 반환 | bounded `IDLE_TIMEOUT/RECOVER`; late checkpoint 미commit; settle 후 STALE/recovery 필요 |
| `close_waits_for_all_tracked_stream_tasks_and_times_out` | effect와 recovery pending 중 close | controller 모두 abort; bound 뒤 `IDLE_TIMEOUT/CLOSE`; settlement까지 DRAINING, 이후 CLOSED |
| `close_after_active_queue_overflow_tracks_retired_writer` | handoff active effect never settles, queue cap 초과 후 close | overflow `QUEUE_OVERFLOW`; close 즉시 success 금지; bound 뒤 `IDLE_TIMEOUT`; late settle 시 draining 0/CLOSED |
| `rejects_oversized_ascii_frame_before_utf8_copy` | `"x".repeat(maxFrameBytes + 1)` | `FRAME_TOO_LARGE`; full-size byte copy 경로 없음 |
| `counts_multibyte_and_lone_surrogate_like_text_encoder` | ASCII/2-byte/3-byte/surrogate pair/lone surrogate 경계 | 기존 byte 의미와 동일한 exact accept/reject |
| `transport_conformance_enforces_max_buffered_bytes_before_enqueue` | push/callback fake transport가 consumer 정지 중 cap+1 byte enqueue | enqueue 거부, lease cancel, raw/message 미노출; provider suite 없이는 promotion 금지 |
실행 명령:
```sh
corepack pnpm exec vitest run tests/unit/browser-rpc/browser-rpc-contract.test.ts
corepack pnpm exec vitest run tests/unit/browser-rpc/browser-rpc-runtime.test.ts
corepack pnpm exec vitest run tests/unit/realtime/stream-coordinator.test.ts
corepack pnpm exec vitest run tests/unit/realtime/live-poll-handoff-coordinator.test.ts
corepack pnpm exec vitest run tests/unit/realtime/websocket-protocol.test.ts
corepack pnpm exec vitest run tests/unit/realtime tests/unit/browser-rpc --reporter=dot --maxWorkers=4
corepack pnpm run check:types:app
corepack pnpm run check:types:test
corepack pnpm run check:realtime-boundaries
corepack pnpm run check:realtime-boundaries:fixture
```
제품 transport 선택 시 별도 필수 evidence:
```sh
# 실제 provider contribution이 script 이름과 target browser matrix를 고정해야 한다.
corepack pnpm run test:browser-rpc-transport-conformance
corepack pnpm run test:browser-rpc-target-browsers
```
## 9. compatibility, migration, rollback
Migration 순서:
1. 새 tests와 lifecycle inspection을 먼저 추가한다. runtime은 미조립 상태라 production traffic 영향은 없다.
2. `createBrowserRpcRuntime`은 raw input을 받아 내부에서 installer를 호출해 기존 caller signature를 유지한다. mutation에 의존한 fixture는 composition-time 오류로 고친다.
3. 한 migration release 동안 기존 `AsyncIterable` transport를 internal adapter로 `BrowserRpcTransportStream`에 감쌀 수 있다. deprecated wrapper의 `waitClosed``iterator.return()` settlement이고 common cleanup bound가 이를 감싼다. provider 선택 전 legacy branch를 제거한다.
4. common stream `close(): Promise<Result>`로 바꾸고 모든 test/향후 composition owner는 `await`한다. 기존 fire-and-forget 호출은 typecheck로 식별한다.
5. handoff retired set을 도입하고 `DRAINING`에는 writer/probe admission을 막는다.
6. WS byte counter는 wire/error shape가 같아 독립적으로 먼저 적용할 수 있다.
7. actual provider/browser/load evidence와 R-01~R-07 closure 전까지 `AVAILABLE_NOT_COMPOSED`를 유지한다. bootstrap composition은 마지막 단계다.
Compatibility 결정:
- application-facing Browser RPC unary/stream port shape는 유지한다.
- wire protocol, frame shape, failure kind, retry owner는 바꾸지 않는다.
- `RealtimeStreamInspection.lifecycle`는 additive다. `close` 반환형은 source-compatible fire-and-forget일 수 있으나 lifecycle correctness를 위해 owner는 await하도록 migration한다.
- exact installer가 과거 extra/accessor/mutable row를 거절하는 것은 의도된 fail-closed tightening이다.
Rollback 순서:
1. traffic admission을 `DISABLED`로 전환한다.
2. connection/runtime lifecycle을 `DRAINING`으로 만들고 actual leases settlement 또는 bounded failure를 기록한다.
3. 살아 있는 lease를 버리고 즉시 이전 runtime을 열지 않는다.
4. source commit을 revert하되 installed snapshot과 allocation-before-cap 수정은 보안/정확성 강화이므로 우선 유지한다.
5. cursor/checkpoint를 합성하지 않고 authoritative snapshot recovery를 수행한다.
6. SSE↔WS, Connect↔gRPC-Web↔REST, live↔Poll을 장애 때문에 즉석 자동 전환하지 않는다. fallback은 registry/ADR에 선언된 새 semantic operation/generation으로만 시작한다.
## 10. 유지할 좋은 설계
1. `RealtimeFailure`/`AppFailure`로 native error, raw close reason, payload, cursor, provider metadata를 경계 밖에 내보내지 않는다.
2. realtime result/registry의 exact own-data snapshot, accessor 거절, immutable recovery checkpoint object identity.
3. fixed same-origin SSE/WS endpoint, URL/subprotocol credential 금지, exact media/subprotocol 검증.
4. SSE, WebSocket, Browser RPC stream, Poll을 서로 다른 delivery/protocol 의미로 유지하고 자동 downgrade/replay하지 않는다.
5. WS inbound/outbound FIFO, count+byte+`bufferedAmount` ceiling과 overflow whole-generation recovery.
6. reconnect의 단일 retry owner, full jitter, hint not-before, finite budget, stable proof 뒤 reset, post-abort DRAINING.
7. Poll의 visible/online finite single-flight lease와 non-cooperative execute/apply tracking.
8. LIVE↔POLL의 one-writer generation, probe buffer, activation 전 quiescence/checkpoint.
9. SSE parser의 incremental strict UTF-8, incomplete EOF discard, bounded reader cancellation.
10. unavailable Browser RPC adapter와 optional runtime host의 `null/UNAVAILABLE`; 조용한 network fallback이 없다.
## 11. false-positive 방지 대조
| 의심 항목 | 최종 판정과 근거 |
|---|---|
| Realtime/Browser RPC가 bootstrap에 조립되지 않음 | 결함 아님. `optional-runtime-host.ts:91-92,143`와 architecture docs가 제품 선택 전 미조립을 요구한다. |
| Common Browser RPC가 Connect/gRPC-Web raw framing을 decode하지 않음 | 결함 아님. `protobuf...md:57-61,381-399`상 concrete transport 책임이다. R-07은 이 미완료 상태를 무시한 promotion만 막는다. |
| Reconnect가 offline 동안 timer 없이 기다림 | 의도. ADR과 `realtime-reconnect-coordinator.test.ts:370-406`가 explicit online signal을 요구한다. |
| healthy session `waitClosed()`에 deadline 없음 | 의도. ADR은 abort 후 drain만 bounded하고 active close receipt는 authoritative하게 기다린다. |
| WS overflow에서 일부 event drop 대신 connection close | 의도. ADR과 `websocket-connection.test.ts:579-651`은 receive pause 없는 classic WS에서 snapshot recovery를 택한다. |
| SSE 204와 incomplete EOF | 각각 terminal/no reconnect와 incomplete discard가 맞다. fetch/parser tests가 확인한다. |
| exact recovery object identity | 의도된 capability token이다. `event-authority.ts:17-23`, reconnect/stream barrier tests가 clone/forgery를 막는다. |
| Handoff overflow 자체 | 이미 fail-close한다. R-03은 overflow 판정이 아니라 그 직후 retired tail reference를 잃는 cleanup bug다. |
| Transport에 effect timeout이 이미 있음 | transport caller는 bounded해도 common `state.tail`은 settle하지 않는다. R-02는 commit fence가 아니라 retained task/liveness 문제다. |
## 12. baseline 검증
1. `corepack pnpm exec vitest run tests/unit/realtime tests/unit/browser-rpc --reporter=dot --maxWorkers=4`
- exit 0, **16 files / 185 tests passed**.
2. `corepack pnpm run check:realtime-boundaries`
- exit 0, `Realtime boundaries: PASS (src)`.
3. `check:realtime-boundaries:fixture` wrapper는 이 sandbox에서 child-process 제한 때문에 진단 없이 exit 1이었다. 같은 allowed/forbidden child 명령을 직접 실행해 allowed exit 0, forbidden exit 1과 세 규칙 `UNSELECTED_REALTIME_RUNTIME_COMPOSED`, `PRESENTATION_INTERVAL_OWNER`, `NATIVE_REALTIME_API_OUTSIDE_ADAPTER`를 확인했다. adapter defect로 세지 않는다.
4. `test:realtime-removal`의 별도 복제에서 범위 tests는 통과했으나 저장소 전체 baseline의 CI authority count drift, 누락 `.npmrc`, child `spawnSync ... EPERM`, architecture report 문제로 최종 exit 1이었다. 검토 범위 failure 증거로 사용하지 않는다.
## 13. 구현 우선순위
1. R-03 retired writer tracking: 국소적이고 확정적인 cleanup bug다.
2. R-02 common stream task lifecycle: SSE/WS 양쪽 liveness 기반을 닫는다.
3. R-01 Browser RPC explicit stream lease와 bounded cleanup.
4. R-04 installed immutable bindings, 이어 R-06 exception/cleanup guard.
5. R-05 allocation-before-cap 제거.
6. R-07 concrete transport conformance는 provider 선택과 함께 수행하되 완료 전 production composition을 금지한다.