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>
40 KiB
40 KiB
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.tstests/unit/browser-rpc/browser-rpc-runtime.test.tstests/unit/realtime/bounded-poll-coordinator.test.tstests/unit/realtime/bounded-polling-policy.test.tstests/unit/realtime/event-codec.test.tstests/unit/realtime/event-consumer.test.tstests/unit/realtime/fetch-sse-connection.test.tstests/unit/realtime/live-poll-handoff-coordinator.test.tstests/unit/realtime/realtime-reconnect-coordinator.test.tstests/unit/realtime/realtime-reconnect-policy.test.tstests/unit/realtime/realtime-stream-registry.test.tstests/unit/realtime/result.test.tstests/unit/realtime/sse-parser.test.tstests/unit/realtime/stream-coordinator.test.tstests/unit/realtime/websocket-connection.test.tstests/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 없이 기다린다. pendingnext()가 signal을 무시하면 async generator의 queuedreturn()도 완료되지 않는다.
- 영향: 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를 숨기지 않는다. - 결정:
- app-facing generator는 idle/total/limit/caller abort 후 cleanup bound 안에 끝난다.
- commit/admission generation은 즉시 fence한다.
- underlying task가 bound 안에 끝나지 않으면 transport lease는
DRAINING에 남고 실제waitClosed()settlement까지 추적한다. 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.
- 결정:
- freshness와 별도로 lifecycle
OPEN | DRAINING | CLOSED를 둔다. - effect/recovery deadline에 commit capability를 영구 false로 만들고 abort한다.
- caller에는
IDLE_TIMEOUT(operation: APPLY | RECOVER, non-retryable)을 bounded하게 반환하고 실제 task는 retain한다. - DRAINING 중 새 event/recovery를 허용하지 않는다. actual settle 뒤
STALE에서 authoritative recovery를 요구하거나 close 요청이면CLOSED로 간다. close()는Promise<RealtimeResult<void>>로 bounded quiescence 결과를 반환한다.
- freshness와 별도로 lifecycle
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 거절은 없다. realtimeresult.test.ts:15-151과 reconnect policy tests에는 exact descriptor snapshot 패턴이 이미 있다. - 적용 패턴: Parse/Validate/Install anti-corruption layer + immutable exact registry snapshot.
- 결정:
- factory 시작 시 registry own descriptors를 한 번 캡처하고 null-prototype exact map으로 복사/freeze한다.
- operation/profile/encoder/schema/mapper/transport row를 허용 key의 own data property로 snapshot한다. getter, extra, symbol, revoked proxy는 composition-time
TypeError다. - runtime과 transport call은 installed snapshot만 사용한다.
- 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()경로에서만 보장된다.
- unary
- 영향: 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,finallycleanup을 이미 사용한다. - 적용 패턴: Result boundary guard + RAII-style finally.
- 결정: clock failure는
SERVER_FAILURE/RPC_RUNTIME_DEPENDENCY_FAILED, capture/isCurrent 실패는 fail-closedSCOPE_GENERATION_CHANGED/RPC_SCOPE_GENERATION_UNAVAILABLE로 canonicalize한다. linked listener/timer는 단일 outerfinally에서 정확히 한 번 해제한다.
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의 bareAsyncIterable에는 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
// 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;
}>;
// 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;
// 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
}>;
// 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
}>;
// 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>>;
}>;
// 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만 리뷰 산출물로 새로 생성했다.
수정:
src/contracts/browser-rpc.ts— exact descriptor snapshot installer와 installed type.src/adapters/browser-rpc/transport.ts— explicit stream lease/cancel/closed receipt.src/adapters/browser-rpc/browser-rpc-runtime.ts— installed snapshot만 사용, bounded stream cleanup/DRAINING, safe clock/fence, outer cleanup.src/adapters/browser-rpc/unavailable-browser-rpc-transport.ts— unavailable stream을 즉시 closed lease로 반환.src/adapters/browser-rpc/index.ts— installed/stream lifecycle types export.src/application/ports/realtime/event-authority.ts— stream lifecycle inspection.src/application/ports/realtime/index.ts—RealtimeStreamLifecycleexport.src/adapters/realtime/stream-coordinator.ts— bounded task lease registry, lifecycle state, async close.src/adapters/realtime/live-poll-handoff-coordinator.ts— retired writer set과 DRAINING convergence.src/adapters/realtime/index.ts— lifecycle/limit types export.src/adapters/realtime/websocket/websocket-protocol.ts— allocation-free bounded UTF-8 counter.tests/unit/browser-rpc/browser-rpc-contract.test.ts— mutation/accessor/extra-key installer tests.tests/unit/browser-rpc/browser-rpc-runtime.test.ts— non-cooperative stream, throwing clock/fence, cleanup balance tests와 fixture lease 전환.tests/unit/realtime/stream-coordinator.test.ts— never-settling effect/recovery, DRAINING/async close tests.tests/unit/realtime/live-poll-handoff-coordinator.test.ts— overflow 뒤 retired writer close test.tests/unit/realtime/websocket-protocol.test.ts— oversize preflight/multibyte/lone-surrogate tests.docs/architecture/protobuf-browser-transport-and-rest-gateway.md— stream lease, buffer owner, promotion evidence.docs/architecture/realtime-events-web-push-and-bounded-polling.md— common stream/handoff DRAINING와 error semantics.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 금지 |
실행 명령:
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:
# 실제 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 순서:
- 새 tests와 lifecycle inspection을 먼저 추가한다. runtime은 미조립 상태라 production traffic 영향은 없다.
createBrowserRpcRuntime은 raw input을 받아 내부에서 installer를 호출해 기존 caller signature를 유지한다. mutation에 의존한 fixture는 composition-time 오류로 고친다.- 한 migration release 동안 기존
AsyncIterabletransport를 internal adapter로BrowserRpcTransportStream에 감쌀 수 있다. deprecated wrapper의waitClosed는iterator.return()settlement이고 common cleanup bound가 이를 감싼다. provider 선택 전 legacy branch를 제거한다. - common stream
close(): Promise<Result>로 바꾸고 모든 test/향후 composition owner는await한다. 기존 fire-and-forget 호출은 typecheck로 식별한다. - handoff retired set을 도입하고
DRAINING에는 writer/probe admission을 막는다. - WS byte counter는 wire/error shape가 같아 독립적으로 먼저 적용할 수 있다.
- 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 순서:
- traffic admission을
DISABLED로 전환한다. - connection/runtime lifecycle을
DRAINING으로 만들고 actual leases settlement 또는 bounded failure를 기록한다. - 살아 있는 lease를 버리고 즉시 이전 runtime을 열지 않는다.
- source commit을 revert하되 installed snapshot과 allocation-before-cap 수정은 보안/정확성 강화이므로 우선 유지한다.
- cursor/checkpoint를 합성하지 않고 authoritative snapshot recovery를 수행한다.
- SSE↔WS, Connect↔gRPC-Web↔REST, live↔Poll을 장애 때문에 즉석 자동 전환하지 않는다. fallback은 registry/ADR에 선언된 새 semantic operation/generation으로만 시작한다.
10. 유지할 좋은 설계
RealtimeFailure/AppFailure로 native error, raw close reason, payload, cursor, provider metadata를 경계 밖에 내보내지 않는다.- realtime result/registry의 exact own-data snapshot, accessor 거절, immutable recovery checkpoint object identity.
- fixed same-origin SSE/WS endpoint, URL/subprotocol credential 금지, exact media/subprotocol 검증.
- SSE, WebSocket, Browser RPC stream, Poll을 서로 다른 delivery/protocol 의미로 유지하고 자동 downgrade/replay하지 않는다.
- WS inbound/outbound FIFO, count+byte+
bufferedAmountceiling과 overflow whole-generation recovery. - reconnect의 단일 retry owner, full jitter, hint not-before, finite budget, stable proof 뒤 reset, post-abort DRAINING.
- Poll의 visible/online finite single-flight lease와 non-cooperative execute/apply tracking.
- LIVE↔POLL의 one-writer generation, probe buffer, activation 전 quiescence/checkpoint.
- SSE parser의 incremental strict UTF-8, incomplete EOF discard, bounded reader cancellation.
- 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 검증
corepack pnpm exec vitest run tests/unit/realtime tests/unit/browser-rpc --reporter=dot --maxWorkers=4- exit 0, 16 files / 185 tests passed.
corepack pnpm run check:realtime-boundaries- exit 0,
Realtime boundaries: PASS (src).
- exit 0,
check:realtime-boundaries:fixturewrapper는 이 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로 세지 않는다.test:realtime-removal의 별도 복제에서 범위 tests는 통과했으나 저장소 전체 baseline의 CI authority count drift, 누락.npmrc, childspawnSync ... EPERM, architecture report 문제로 최종 exit 1이었다. 검토 범위 failure 증거로 사용하지 않는다.
13. 구현 우선순위
- R-03 retired writer tracking: 국소적이고 확정적인 cleanup bug다.
- R-02 common stream task lifecycle: SSE/WS 양쪽 liveness 기반을 닫는다.
- R-01 Browser RPC explicit stream lease와 bounded cleanup.
- R-04 installed immutable bindings, 이어 R-06 exception/cleanup guard.
- R-05 allocation-before-cap 제거.
- R-07 concrete transport conformance는 provider 선택과 함께 수행하되 완료 전 production composition을 금지한다.