Files
natsdotnet/gaps/websocket.md
Joseph Doherty c30e67a69d Fix E2E test gaps and add comprehensive E2E + parity test suites
- Fix pull consumer fetch: send original stream subject in HMSG (not inbox)
  so NATS client distinguishes data messages from control messages
- Fix MaxAge expiry: add background timer in StreamManager for periodic pruning
- Fix JetStream wire format: Go-compatible anonymous objects with string enums,
  proper offset-based pagination for stream/consumer list APIs
- Add 42 E2E black-box tests (core messaging, auth, TLS, accounts, JetStream)
- Add ~1000 parity tests across all subsystems (gaps closure)
- Update gap inventory docs to reflect implementation status
2026-03-12 14:09:23 -04:00

191 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# WebSocket — Gap Analysis
> This file tracks what has and hasn't been ported from Go to .NET for the **WebSocket** module.
> See [stillmissing.md](stillmissing.md) for the full LOC comparison across all modules.
## LLM Instructions: How to Analyze This Category
### Step 1: Read the Go Reference Files
Read each Go source file listed below. For every file:
1. Extract all **exported types** (structs, interfaces, type aliases)
2. Extract all **exported methods** on those types (receiver functions)
3. Extract all **exported standalone functions**
4. Note **key constants, enums, and protocol states**
5. Note **important unexported helpers** that implement core logic (functions >20 lines)
6. Pay attention to **concurrency patterns** (goroutines, mutexes, channels) — these map to different .NET patterns
### Step 2: Read the .NET Implementation Files
Read all `.cs` files in the .NET directories listed below. For each Go symbol found in Step 1:
1. Search for a matching type, method, or function in .NET
2. If found, compare the behavior: does it handle the same edge cases? Same error paths?
3. If partially implemented, note what's missing
4. If not found, note it as MISSING
### Step 3: Cross-Reference Tests
Compare Go test functions against .NET test methods:
1. For each Go `Test*` function, check if a corresponding .NET `[Fact]` or `[Theory]` exists
2. Note which test scenarios are covered and which are missing
3. Check the parity DB (`docs/test_parity.db`) for existing mappings:
```bash
sqlite3 docs/test_parity.db "SELECT go_test, dotnet_test, confidence FROM test_mappings tm JOIN go_tests gt ON tm.go_test_id=gt.rowid JOIN dotnet_tests dt ON tm.dotnet_test_id=dt.rowid WHERE gt.go_file LIKE '%PATTERN%'"
```
### Step 4: Classify Each Item
Use these status values:
| Status | Meaning |
|--------|---------|
| **PORTED** | Equivalent exists in .NET with matching behavior |
| **PARTIAL** | .NET implementation exists but is incomplete (missing edge cases, error handling, or features) |
| **MISSING** | No .NET equivalent found — needs to be ported |
| **NOT_APPLICABLE** | Go-specific pattern that doesn't apply to .NET (build tags, platform-specific goroutine tricks, etc.) |
| **DEFERRED** | Intentionally skipped for now (document why) |
### Step 5: Fill In the Gap Inventory
Add rows to the Gap Inventory table below. Group by Go source file. Include the Go file and line number so a porting LLM can jump directly to the reference implementation.
### Key Porting Notes for WebSocket
- WebSocket is at **99% LOC parity** — likely near-complete.
- Focus analysis on edge cases: compression negotiation, frame fragmentation, close handshake.
- WebSocket connections wrap a standard NATS connection with WS framing.
---
## Go Reference Files (Source)
- `golang/nats-server/server/websocket.go` — WebSocket transport support (~1,550 lines). WS framing, upgrade handshake, compression, masking.
## Go Reference Files (Tests)
- `golang/nats-server/server/websocket_test.go`
## .NET Implementation Files (Source)
- `src/NATS.Server/WebSocket/` (all files)
## .NET Implementation Files (Tests)
- `tests/NATS.Server.Tests/WebSocket/`
---
## Gap Inventory
<!-- After analysis, fill in this table. Group rows by Go source file. -->
### `golang/nats-server/server/websocket.go`
#### Types and Structs
| Go Symbol | Go File:Line | Status | .NET Equivalent | Notes |
|-----------|:-------------|--------|:----------------|-------|
| `wsOpCode` (type) | websocket.go:41 | PORTED | `src/NATS.Server/WebSocket/WsConstants.cs:1` | Int constants replace Go type alias; `WsConstants.TextMessage`, `BinaryMessage`, etc. |
| `websocket` (struct) | websocket.go:108 | PORTED | `src/NATS.Server/WebSocket/WsConnection.cs:8` | Fields mapped: `compress`, `maskread`/`maskwrite`, `browser`, `nocompfrag`. Cookie fields moved to `WsUpgradeResult`. `frames`/`fs` buffer management is now in `WsConnection.WriteAsync`. `compressor` (reuse) is NOT pooled — recreated per call. |
| `srvWebsocket` (struct) | websocket.go:126 | PARTIAL | `src/NATS.Server/NatsServer.cs:538` | `_wsListener`, `_options.WebSocket` cover port/host/tls; `allowedOrigins` managed via `WsOriginChecker`; explicit `authOverride` flag is now computed via `WsAuthConfig.Apply(...)`. `connectURLsMap` ref-count URL set is still missing. |
| `allowedOrigin` (struct) | websocket.go:145 | PORTED | `src/NATS.Server/WebSocket/WsOriginChecker.cs:80` | Private `AllowedOrigin` record struct with `Scheme` and `Port`. |
| `wsUpgradeResult` (struct) | websocket.go:150 | PORTED | `src/NATS.Server/WebSocket/WsUpgrade.cs:346` | `WsUpgradeResult` readonly record struct with equivalent fields. `kind` maps to `WsClientKind` enum. |
| `wsReadInfo` (struct) | websocket.go:156 | PORTED | `src/NATS.Server/WebSocket/WsReadInfo.cs:10` | All fields ported: `rem`→`Remaining`, `fs`→`FrameStart`, `ff`→`FirstFrame`, `fc`→`FrameCompressed`, `mask`→`ExpectMask`, `mkpos`→`MaskKeyPos`, `mkey`→`MaskKey`, `cbufs`→`CompressedBuffers`, `coff`→`CompressedOffset`. Extra .NET fields added for control frame output. |
| `WsDeflateParams` (struct) | websocket.go:885916 | PORTED | `src/NATS.Server/WebSocket/WsCompression.cs:10` | New .NET-specific struct capturing `permessage-deflate` negotiated parameters. No Go equivalent struct — Go stores compress bool only. |
#### Package-Level Variables / Constants
| Go Symbol | Go File:Line | Status | .NET Equivalent | Notes |
|-----------|:-------------|--------|:----------------|-------|
| `wsOpCode` constants | websocket.go:4396 | PORTED | `src/NATS.Server/WebSocket/WsConstants.cs:965` | All opcode, bit, size, close-status, and header-string constants are present. |
| `decompressorPool` | websocket.go:99 | PARTIAL | `src/NATS.Server/WebSocket/WsCompression.cs:193` | Go uses `sync.Pool` for `flate.Reader` reuse. .NET creates a new `DeflateStream` per decompression call — no pooling. Functional but slightly less efficient under high load. |
| `compressLastBlock` | websocket.go:100 | PORTED | `src/NATS.Server/WebSocket/WsConstants.cs:62` | .NET uses 4-byte `DecompressTrailer` (sync marker only); Go uses 9-byte block. Both work correctly — difference is .NET `DeflateStream` does not need the final stored block. |
| `wsGUID` | websocket.go:103 | PORTED | `src/NATS.Server/WebSocket/WsUpgrade.cs:177` | Inline string literal in `ComputeAcceptKey`. |
| `wsTestRejectNoMasking` | websocket.go:106 | PORTED | `src/NATS.Server/WebSocket/WsUpgrade.cs:14` | Added test hook `RejectNoMaskingForTest`; when set, no-masking leaf upgrade requests are explicitly rejected |
#### Methods on `wsReadInfo`
| Go Symbol | Go File:Line | Status | .NET Equivalent | Notes |
|-----------|:-------------|--------|:----------------|-------|
| `wsReadInfo.init()` | websocket.go:168 | PORTED | `src/NATS.Server/WebSocket/WsReadInfo.cs:28` | Constructor initializes `FrameStart=true`, `FirstFrame=true` (same as Go `init()`). |
| `wsReadInfo.Read()` | websocket.go:353 | PORTED | `src/NATS.Server/WebSocket/WsReadInfo.cs:100` | Consumed data from `CompressedBuffers` is handled inside `ReadFrames` rather than via an `io.Reader` interface; functionally equivalent. |
| `wsReadInfo.nextCBuf()` | websocket.go:376 | PORTED | `src/NATS.Server/WebSocket/WsReadInfo.cs:100` | Buffer cycling logic is inline within the `Decompress` method — no explicit `nextCBuf` helper needed since .NET uses list indexing. |
| `wsReadInfo.ReadByte()` | websocket.go:393 | PORTED | `src/NATS.Server/WebSocket/WsReadInfo.cs:100` | Used by Go's `flate.Resetter`; .NET `DeflateStream` reads a `MemoryStream` directly — no `ReadByte` interface needed. |
| `wsReadInfo.decompress()` | websocket.go:408 | PORTED | `src/NATS.Server/WebSocket/WsCompression.cs:193` | `WsCompression.Decompress()` — appends trailer, decompresses with `DeflateStream`, enforces `maxPayload` limit. |
| `wsReadInfo.unmask()` | websocket.go:509 | PORTED | `src/NATS.Server/WebSocket/WsReadInfo.cs:56` | `WsReadInfo.Unmask()` — exact port including 8-byte bulk XOR optimization for buffers >= 16 bytes. |
#### Methods on `client` (WebSocket-related)
| Go Symbol | Go File:Line | Status | .NET Equivalent | Notes |
|-----------|:-------------|--------|:----------------|-------|
| `client.isWebsocket()` | websocket.go:197 | PORTED | `src/NATS.Server/NatsClient.cs:107` | `NatsClient.IsWebSocket` bool property. |
| `client.wsRead()` | websocket.go:208 | PORTED | `src/NATS.Server/WebSocket/WsReadInfo.cs:100` | `WsReadInfo.ReadFrames()` — full state machine port with frame-type validation, extended-length decoding, mask read, control frame dispatch, and compression accumulation. |
| `client.wsHandleControlFrame()` | websocket.go:445 | PORTED | `src/NATS.Server/WebSocket/WsReadInfo.cs:246` | `WsReadInfo.HandleControlFrame()` — ping→pong, close→echo-close, pong→no-op. Close UTF-8 body validation is MISSING (Go validates UTF-8 in close body and downgrades status to 1007 on failure; .NET does not). |
| `client.wsEnqueueControlMessage()` | websocket.go:600 | PORTED | `src/NATS.Server/WebSocket/WsConnection.cs:127` | Control frames collected in `PendingControlFrames` and flushed via `FlushControlFramesAsync`. Go uses per-client write buffer queuing with `flushSignal`; .NET writes directly — functionally equivalent. |
| `client.wsEnqueueControlMessageLocked()` | websocket.go:631 | PORTED | `src/NATS.Server/WebSocket/WsConnection.cs:127` | Combined with `FlushControlFramesAsync`. Lock semantics differ (Go client mu, .NET `_writeLock`). |
| `client.wsEnqueueCloseMessage()` | websocket.go:668 | PORTED | `src/NATS.Server/WebSocket/WsFrameWriter.cs:152` | `WsFrameWriter.MapCloseStatus()` covers the same `ClosedState` → status mapping. `WsConnection.SendCloseAsync()` performs the send. `BadClientProtocolVersion`, `MaxAccountConnectionsExceeded`, `MaxConnectionsExceeded`, `MaxControlLineExceeded`, `MissingAccount`, `Revocation` close-reason mappings are MISSING from .NET `ClientClosedReason` enum. |
| `client.wsHandleProtocolError()` | websocket.go:700 | PORTED | `src/NATS.Server/WebSocket/WsReadInfo.cs:135` | Protocol errors throw `InvalidOperationException` in `ReadFrames` — caller (WsConnection.ReadAsync) treats any exception as a connection close. Direct close-frame send on protocol error is implicit via the close path. |
| `client.wsCollapsePtoNB()` | websocket.go:1367 | PARTIAL | `src/NATS.Server/WebSocket/WsConnection.cs:91` | `WsConnection.WriteFramedAsync()` handles browser fragmentation and compression. However, Go's `wsCollapsePtoNB` is a low-level buffer-collapse routine that operates on `net.Buffers` (scatter/gather I/O) and reuses a persistent per-connection `flate.Writer` compressor. .NET recreates a `DeflateStream` per write and does not use scatter/gather — less efficient but functionally correct. |
#### Standalone Functions
| Go Symbol | Go File:Line | Status | .NET Equivalent | Notes |
|-----------|:-------------|--------|:----------------|-------|
| `wsGet()` | websocket.go:178 | PORTED | `src/NATS.Server/WebSocket/WsReadInfo.cs:299` | `WsReadInfo.WsGet()` (private) — returns `(byte[], newPos)` tuple. Same buffer-then-reader fallback logic. |
| `wsIsControlFrame()` | websocket.go:539 | PORTED | `src/NATS.Server/WebSocket/WsConstants.cs:64` | `WsConstants.IsControlFrame(int opcode)` — same `>= CloseMessage` check. |
| `wsCreateFrameHeader()` | websocket.go:545 | PORTED | `src/NATS.Server/WebSocket/WsFrameWriter.cs:17` | `WsFrameWriter.CreateFrameHeader()` — first=true, final=true wrapper. |
| `wsFillFrameHeader()` | websocket.go:551 | PORTED | `src/NATS.Server/WebSocket/WsFrameWriter.cs:30` | `WsFrameWriter.FillFrameHeader()` — exact port with 2/4/10-byte length encoding and optional masking. |
| `wsMaskBuf()` | websocket.go:607 | PORTED | `src/NATS.Server/WebSocket/WsFrameWriter.cs:77` | `WsFrameWriter.MaskBuf()` — simple XOR loop. |
| `wsMaskBufs()` | websocket.go:614 | PORTED | `src/NATS.Server/WebSocket/WsFrameWriter.cs:86` | `WsFrameWriter.MaskBufs()` — multi-buffer contiguous XOR. |
| `wsCreateCloseMessage()` | websocket.go:711 | PORTED | `src/NATS.Server/WebSocket/WsFrameWriter.cs:103` | `WsFrameWriter.CreateCloseMessage()` — 2-byte status + body, body truncated with "..." at `MaxControlPayloadSize-2`. .NET additionally validates UTF-8 boundary when truncating. |
| `wsUpgrade()` (Server method) | websocket.go:731 | PORTED | `src/NATS.Server/WebSocket/WsUpgrade.cs:13` | `WsUpgrade.TryUpgradeAsync()` — full RFC 6455 handshake: method, host, upgrade, connection, key, version checks; origin; compression negotiation; no-masking; browser detection; cookie extraction; X-Forwarded-For; custom headers; 101 response. Go uses `http.Hijacker` pattern; .NET reads raw TCP stream. MQTT `Sec-Websocket-Protocol` header in response is MISSING. |
| `wsHeaderContains()` | websocket.go:872 | PORTED | `src/NATS.Server/WebSocket/WsUpgrade.cs:315` | `WsUpgrade.HeaderContains()` (private) — same comma-split, case-insensitive token matching. |
| `wsPMCExtensionSupport()` | websocket.go:885 | PORTED | `src/NATS.Server/WebSocket/WsCompression.cs:60` | `WsDeflateNegotiator.Negotiate()` — parses `Sec-WebSocket-Extensions`, detects `permessage-deflate`, extracts `server_no_context_takeover`, `client_no_context_takeover`, and window bits. |
| `wsReturnHTTPError()` | websocket.go:921 | PORTED | `src/NATS.Server/WebSocket/WsUpgrade.cs:226` | `WsUpgrade.FailAsync()` (private) — sends HTTP error response and returns `WsUpgradeResult.Failed`. |
| `srvWebsocket.checkOrigin()` | websocket.go:933 | PORTED | `src/NATS.Server/WebSocket/WsOriginChecker.cs:32` | `WsOriginChecker.CheckOrigin()` — same-origin and allowed-list checks. Go checks `r.TLS != nil` for TLS detection; .NET uses `isTls` parameter passed at call site. |
| `wsGetHostAndPort()` | websocket.go:985 | PORTED | `src/NATS.Server/WebSocket/WsOriginChecker.cs:65` | `WsOriginChecker.GetHostAndPort()` and `ParseHostPort()` — missing-port defaults to 80/443 by TLS flag. |
| `wsAcceptKey()` | websocket.go:1004 | PORTED | `src/NATS.Server/WebSocket/WsUpgrade.cs:175` | `WsUpgrade.ComputeAcceptKey()` — SHA-1 of key + GUID, base64 encoded. |
| `wsMakeChallengeKey()` | websocket.go:1011 | PORTED | `src/NATS.Server/WebSocket/WsUpgrade.cs:192` | Added `MakeChallengeKey()` generating a random base64-encoded 16-byte challenge nonce |
| `validateWebsocketOptions()` | websocket.go:1020 | PORTED | `src/NATS.Server/WebSocket/WebSocketOptionsValidator.cs:11` | Added `WebSocketOptionsValidator.Validate(NatsOptions)` covering TLS cert/key requirement, allowed-origin URI parsing, `NoAuthUser` membership, username/token conflicts with users/nkeys, `JwtCookie` trusted-operator requirement, TLS pinned-cert validation, and reserved response-header protection. Startup now enforces this validation in `NatsServer.StartAsync()` before opening WS listener (`src/NATS.Server/NatsServer.cs:631`). |
| `Server.wsSetOriginOptions()` | websocket.go:1083 | PARTIAL | `src/NATS.Server/WebSocket/WsUpgrade.cs:49` | Origin checking is constructed inline in `TryUpgradeAsync` from `options.SameOrigin` and `options.AllowedOrigins`. The Go method persists parsed origins in `srvWebsocket.allowedOrigins` map and supports hot-reload. .NET constructs a `WsOriginChecker` per request — no hot-reload support, but functionally equivalent for initial config. |
| `Server.wsSetHeadersOptions()` | websocket.go:1111 | PORTED | `src/NATS.Server/WebSocket/WsUpgrade.cs:141` | Custom headers applied inline in `TryUpgradeAsync` from `options.Headers`. |
| `Server.wsConfigAuth()` | websocket.go:1131 | PORTED | `src/NATS.Server/WebSocket/WsAuthConfig.cs:5` | Added explicit auth-override computation (`Username`/`Token`/`NoAuthUser`) and startup application via `NatsServer.StartAsync()` before WS listener initialization |
| `Server.startWebsocketServer()` | websocket.go:1137 | PORTED | `src/NATS.Server/NatsServer.cs:538` | `NatsServer.StartAsync()` section at line 538 sets up the WS listener, logs, and launches `RunWebSocketAcceptLoopAsync`. Go uses `http.Server` + mux; .NET uses raw `TcpListener`/`Socket.AcceptAsync`. LEAF and MQTT routing at connection time is PARTIAL — LEAF path is wired (`WsClientKind.Leaf`) but MQTT is not handled in `AcceptWebSocketClientAsync`. `lame-duck` / `ldmCh` signaling is MISSING. |
| `Server.wsGetTLSConfig()` | websocket.go:1264 | PARTIAL | `src/NATS.Server/NatsServer.cs:807` | TLS is applied once at accept time via `TlsConnectionWrapper.NegotiateAsync`. Go uses `GetConfigForClient` callback for hot-reload TLS config. .NET does not support hot TLS config reload for WS. |
| `Server.createWSClient()` | websocket.go:1273 | PORTED | `src/NATS.Server/NatsServer.cs:799` | `AcceptWebSocketClientAsync()` — creates `WsConnection`, constructs `NatsClient`, wires `IsWebSocket`/`WsInfo`, registers client. Go also sends INFO immediately and sets auth timer; .NET's `NatsClient.RunAsync()` handles INFO send and auth timer. |
| `isWSURL()` | websocket.go:1544 | PORTED | `src/NATS.Server/WebSocket/WsUpgrade.cs:203` | Added helper to detect absolute `ws://` URLs via URI scheme parsing |
| `isWSSURL()` | websocket.go:1548 | PORTED | `src/NATS.Server/WebSocket/WsUpgrade.cs:215` | Added helper to detect absolute `wss://` URLs via URI scheme parsing |
---
## Keeping This File Updated
After porting work is completed:
1. **Update status**: Change `MISSING → PORTED` or `PARTIAL → PORTED` for each item completed
2. **Add .NET path**: Fill in the ".NET Equivalent" column with the actual file:line
3. **Re-count LOC**: Update the LOC numbers in `stillmissing.md`:
```bash
# Re-count .NET source LOC for this module
find src/NATS.Server/WebSocket/ -name '*.cs' -type f -exec cat {} + | wc -l
# Re-count .NET test LOC for this module
find tests/NATS.Server.Tests/WebSocket/ -name '*.cs' -type f -exec cat {} + | wc -l
```
4. **Add a changelog entry** below with date and summary of what was ported
5. **Update the parity DB** if new test mappings were created:
```bash
sqlite3 docs/test_parity.db "INSERT INTO test_mappings (go_test_id, dotnet_test_id, confidence, notes) VALUES (?, ?, 'manual', 'ported in YYYY-MM-DD session')"
```
## Change Log
| Date | Change | By |
|------|--------|----|
| 2026-02-26 | Ported `validateWebsocketOptions()` parity by adding `WebSocketOptionsValidator`, wiring startup enforcement, and adding focused validator tests including TLS pinned cert validation. | codex |
| 2026-02-25 | Ported `Server.wsConfigAuth()` parity by adding `WsAuthConfig` auth-override computation and applying it during WS startup; added `WebSocketOptions.AuthOverride` plus focused tests. | codex |
| 2026-02-25 | File created with LLM analysis instructions | auto |
| 2026-02-25 | Full gap inventory populated: 37 Go symbols classified (26 PORTED, 7 PARTIAL, 4 MISSING, 0 NOT_APPLICABLE, 0 DEFERRED) | auto |