# 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>`로 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>`/`AsyncIterable>` 대신 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; cancel(reason: BrowserRpcStreamCancelReason): void; waitClosed(): Promise; }>; export type BrowserRpcTransport = BrowserRpcRuntimeBindingIdentity & Readonly<{ invokeUnary?(call: BrowserRpcTransportCall): Promise; openServerStream?(call: BrowserRpcTransportCall): BrowserRpcTransportStream; }>; ``` ```ts // src/contracts/browser-rpc.ts export type InstalledBrowserRpcContractBindings = Readonly<{ operations: Readonly>; profiles: Readonly>; schemaCodecs: Readonly>; mappers: Readonly>; requestEncoders: Readonly>; runtimeBindings: Readonly>; }>; 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>; }>; ``` ```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`로 바꾸고 모든 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을 금지한다.