- 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
191 lines
18 KiB
Markdown
191 lines
18 KiB
Markdown
# 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:885–916 | 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:43–96 | PORTED | `src/NATS.Server/WebSocket/WsConstants.cs:9–65` | 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 |
|