Changelog
All notable changes to this project are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
5.2.0
Running the Autobahn Testsuite against the client for the first time — the one surface that had never been verified — found two defects that no unit test had reached.
Fixed
- A frame arriving in the same TCP segment as the handshake was stalled until more data arrived.
The upgrade parser marked the leftover bytes examined without consuming them, which in
System.IO.Pipelinesmeans the next read waits for new data. A peer that pipelines its first frame onto the handshake and then goes quiet — exactly what the test suite does — was never seen at all. Measured on the client: noticing a Close frame took 25 s before the fix and 34 ms after, withDisposeAsyncdropping from 5 s to 0 ms. Present on both the server and the client. - Zero-length messages were dropped by the clients and the TCP server, the same middleware "empty means suppressed" confusion fixed in the WebSocket server in 5.0.0. An empty message is legal (RFC 6455 Section 5.2) and is used as a keepalive by real clients.
Added
StopAsync(TimeSpan drainTimeout, CancellationToken)on both servers, overridingServerOptions.ShutdownDrainTimeoutfor a single call.Timeout.InfiniteTimeSpanwaits as long as the caller's token allows.- The client conformance suite runs in CI alongside the server one
(
.github/workflows/autobahn.yml), and a nightly job (.github/workflows/nightly.yml) drives connection churn, message load and abnormal teardowns, failing if sessions, groups, connections or file descriptors do not return to zero. docs/comparison.md— where SignalR, Kestrel, NetCoreServer and the smaller libraries fit, and when to use them instead.
Conformance
| Suite | Result |
|---|---|
| Server, correctness sections | 247 / 247 |
| Client | 463 / 463 (72 reported UNIMPLEMENTED: the compression window parameter this library declines) |
5.1.0
Integration and lifecycle work on top of 5.0.0: a StormSocket server can now be part of a .NET Generic Host instead of something an application starts on the side.
Added — hosting and dependency injection
New package
StormSocket.Extensions.Hosting. The core package stays dependency-free; install this one to run a server as part of a .NET Generic Host or ASP.NET Core application.builder.Services .AddStormWebSocketServer(options => options.MaxConnections = 10_000) .ListenOnAnyIP(8080) .AddHandler<ChatHandler>(); builder.Services.AddHealthChecks().AddStormWebSocketServer();IWebSocketHandler/ITcpConnectionHandlerare resolved from the container. Scoped by default, so injecting aDbContextbehaves as it does in a web request; register a handler as a singleton to skip the per-message scope on a hot path. Several handlers run in registration order, and one that throws is logged without depriving the others or dropping the connection.- The server starts and stops with the host, drains in-flight work on shutdown, and logs through
the application's
ILoggerFactorywith no extra wiring. A failed bind fails the host's startup instead of leaving it running with a dead server. - Health checks report the listening state and active connection count.
- See the hosting guide and
samples/StormSocket.Samples.AspNetCore.
Added — production lifecycle
StopAsync(CancellationToken)drains in-flight connections. Shutdown now stops accepting, closes sessions, and waits for connection handlers to finish, bounded by the newServerOptions.ShutdownDrainTimeout(10 s) and the caller's token — so a pod terminating on a grace period finishes the work it can and never hangs past it. It never throws on timeout.IsRunningon both servers, for health checks and diagnostics.StartAsyncfails cleanly. A throwing bind used to leave the listen socket alive and assigned; it is now disposed,IsRunningstays false andLocalEndPointstays null. Starting twice throwsInvalidOperationExceptioninstead of leaking the previous socket.- Options validation.
ServerOptions,WebSocketOptions,SslOptions,SocketTuningOptions,ClientOptions,WsClientOptionsandRateLimitOptionsgained aValidate()that runs atStartAsync/ConnectAsync. Configurations that used to fail obscurely much later — a null certificate surfacing as aNullReferenceExceptioninside the TLS handshake of every connection,DualModewith a Unix socket throwing anInvalidCastExceptionin the accept loop — now fail at startup with a message naming the property.StormTcpServeralso warns whenWebSocketoptions are set on it, which it silently ignores. - Options properties changed from
inittoset. Source-compatible — object initializers still compile unchanged — but code compiled against 5.0.0 and swapped onto this assembly without recompiling will not find the old setters; rebuild rather than replacing the DLL in place. The point is thatAddStormWebSocketServer(o => o.MaxConnections = ...)and configuration binding now compile. Note that servers snapshotWebSocket,MaxConnectionsandMaxConnectionsPerIpin their constructor, so configure before constructing.
Fixed
- Disposing an aborted session could hang forever. Teardown cancelled the token and then waited for the receive loop — but cancelling a token does not interrupt a socket receive that is already in flight, and the socket was only closed after that wait. With a connected, silent peer the wait never ended. The socket is shut down before the loop is awaited, and the wait is bounded.
5.0.0
A correctness and hardening release. The WebSocket layer framed and routed messages correctly but never validated them, so peers could drive the server outside the protocol; several of those paths were remotely reachable. Every change below is covered by a regression test.
Conformance
- The Autobahn Testsuite now runs in CI. The correctness sections pass 247/247; the
permessage-deflate sections pass 180/216 with 36 cases reported
UNIMPLEMENTED, which is the window-size parameter this library declines rather than accepts and ignores. - Running it caught two defects no unit test had: a zero-length message was never delivered to the application (the middleware hook's "empty means suppressed" convention could not tell an empty payload from a suppressed one), and a connection failed by the server waited for a Close frame it could no longer read, so the socket lingered and peers reported the close as failed.
Security
- permessage-deflate decompression is now bounded.
Decompressinflated into an unboundedMemoryStream, so a single frame that passedMaxFrameSizecould expand without limit — a 261 KB frame produced a 256 MB message and drove the process to 690 MB. Inflation now stops atMaxMessageSizeand fails the connection with 1009. - The HTTP upgrade request is now bounded and scanned incrementally. There was no header size or
count limit, and every read rescanned the whole accumulated buffer from byte 0 with a byte-by-byte
matcher. One connection could push 30 MB in the handshake window and cost the server 813 MB of
allocations. New limits:
WebSocketOptions.MaxRequestHeaderBytes(16 KB) andMaxRequestHeaderCount(100), answered with431. - Header injection through echoed values is rejected. A bare LF inside a header value survived
parsing, so
Sec-WebSocket-Protocol: chat<LF>X-Injected: yescould add an attacker-controlled header to the 101 response. Header names must now be RFC 7230 tokens and values may not contain CR/LF or other control characters. - Connection limits count connections that are still handshaking.
MaxConnectionscompared against established sessions only, so sockets parked in TLS or the upgrade were never counted and the limit could be walked straight past. - New
ServerOptions.MaxConnectionsPerIpbounds concurrent connections from a single address. - New
ServerOptions.TlsHandshakeTimeout(10 s). The TLS handshake previously had no timeout at all, so a peer that stalled mid-handshake held a socket, two pipes and a task indefinitely. - Rate limiting no longer resets the budget it is supposed to enforce. Tripping the limit with
Scope.IpAddressremoved the whole IP entry, handing every other connection from that address a fresh counter. The window is now sliding by default (RateLimitOptions.SlidingWindow), and control frames and fragments are metered too (RateLimitOptions.CountControlFrames), closing a ping-flood amplification where each ping was auto-ponged for free.
RFC 6455 / RFC 7692 compliance
The README previously claimed full RFC 6455 compliance. These were the gaps:
- Masking is enforced in both directions. The MASK bit was decoded and then never read by anything: servers accepted unmasked client frames and clients accepted masked server frames.
- Text payloads are validated as UTF-8 by an incremental validator that carries state across fragments, failing with 1007. Invalid sequences were previously replaced with U+FFFD and handed to the application as if they were valid — the corruption was silent.
- Close frames are validated: reserved and unassigned codes (1004, 1005, 1006, 1012-2999, 5000+) fail the connection with 1002 instead of being echoed back onto the wire, a one-byte body is a protocol error, and the reason must be valid UTF-8.
- Exactly one Close frame per connection. Every close path previously sent a second one, so peers saw the diagnosed status followed by a plain 1000.
- The closing handshake waits for the peer (
WebSocketOptions.CloseTimeout, 5 s) before dropping TCP, so a peer that is closed by the server reports the real status instead of 1006. - Fragmented control frames fail the connection. The check existed but was unreachable: both read loops routed control frames around the layer that performed it, so only the unit tests exercised it.
- Frames are no longer processed after a Close has been received.
- A 64-bit payload length with the most significant bit set is a protocol error. It previously
became a negative length that slipped past every size guard and surfaced as an unhandled
ArgumentOutOfRangeException, tearing the connection down with no Close frame. - Non-minimal length encodings and RSV1 on control or continuation frames are rejected.
- The handshake is validated:
GETwith HTTP/1.1 or later, aHostheader, and aSec-WebSocket-Keythat base64-decodes to exactly 16 bytes.POST / HTTP/1.0with a garbage key previously returned101 Switching Protocols. UpgradeandConnectionare matched as comma-separated token lists, soUpgrade: websocket, h2cis accepted and substring matches no longer pass.- Duplicate
Host,Sec-WebSocket-KeyandSec-WebSocket-Versionheaders are rejected; other repeated headers are combined per RFC 7230 instead of last-one-wins. - A version mismatch answers
426 Upgrade Required(was400), keepingSec-WebSocket-Version: 13. - permessage-deflate is negotiated by parsing, not substring matching.
server_max_window_bitswas silently ignored, producing a stream the peer could not inflate; offers that require a window this library cannot honor are now declined, andclient_max_window_bitsis never sent unsolicited.
Fixed
- Concurrent sends on a TCP session corrupted the wire.
PipeConnection.SendAsyncwrote to thePipeWriterwith no synchronization whileWebSocketSessionhad a write lock. Two threads sending on one session interleavedGetSpan/Advance: a repro produced a garbage length prefix and lost half the bytes. The TCP path now uses the same fast-path write lock. - permessage-deflate compression ran outside the write lock, so concurrent sends mutated shared
deflate state (
ObjectDisposedExceptionin practice) and could emit frames whose deflate order did not match wire order. CloseAsyncdropped queued data. Both transports completed the send-pipe writer and cancelled the token immediately; 4 MB queued before a close delivered 621 KB. The send loop now drains, bounded by a timeout.- WebSocket client heartbeat timeout deadlocked the frame loop through a cycle back into the
heartbeat task, leaking the transport — and
DisposeAsyncstill returned successfully, so the leak was silent. ConnectAsynccould hang forever with reconnect enabled when the token was cancelled or the first attempt threw: the promise was never completed and the exception was never observed.- The WebSocket client never sent a Close frame on
DisconnectAsync: the state was set toClosingbefore the write, and the write path skips anything that is notConnected. - Client transports leaked on every post-handshake connect failure, one socket and two loop tasks per attempt, forever, when reconnect was enabled.
ConnectTimeoutnow covers the whole connect sequence (DNS, TCP, TLS, upgrade), not just the TCP connect; the buffered 101 response is capped.- Multicast async events dropped every subscriber but the last. With two handlers attached, the
first one's
ValueTaskwas never awaited: ordering was lost and its exceptions surfaced asTaskScheduler.UnobservedTaskExceptioninstead of reachingOnError. All events now await every subscriber in registration order, each isolated by its own try/catch. - Sessions could stay in a group forever.
RemoveFromAllran before the disconnect handlers, so aJoinGroupfromOnDisconnectedre-inserted a dead session with nothing left to remove it; 60 connect/disconnect cycles left 60 phantom members broadcasting into disposed transports. - A concurrent group add/remove could detach a member silently — it believed it was in the group while the group no longer contained it.
SlowConsumerPolicyand the pipe limits were ignored on TLS connections.SslTransportfell back to the 64 KBPipeOptions.Default, so every configured backpressure limit was a no-op onwss://.- The receive pipe was never completed, so its pooled segments were never returned.
Session.Itemsis now a concurrent dictionary; it is reachable from the read loop, the timers and application threads at the same time.- A throwing
OnConnectinghandler now rejects the connection with 500 instead of being reported as a transport error; a throwing handler in the TCP client no longer skips transport disposal or kills the read loop. - Client TLS: an empty
TargetHostfalls back to the URI/endpoint host instead of producing an empty SNI name; newClientSslOptions.CheckCertificateRevocation.
Performance
- Payload unmasking is vectorized. The 4-byte key is widened to a vector (or machine word) rather than XORed byte at a time. Decoding a 1 KB frame went from 586 ns to 109 ns, and an 8 KB frame from 4.42 us to 486 ns. A 32-byte frame costs about 7 ns more than before, which buys the protocol validation above.
- The per-frame payload allocation is gone. Masked payloads are unmasked into a buffer the connection reuses instead of a fresh array per frame: server-side allocation over a 25M-message run dropped from 156 to 109 bytes per message and gen0 collections from 46 to 28.
- The frame header is read in place when the read buffer is a single segment, instead of being copied into scratch space for every frame.
Added
StormTcpServer.LocalEndPoint/StormWebSocketServer.LocalEndPoint, so binding to port 0 and discovering the assigned port is possible (useful in tests).WebSocketSession.CloseAsync(WsCloseStatus, CancellationToken)for closing with an explicit status.IConnectionMiddleware.OnFrameReceivedAsync, called for every decoded frame including control frames and fragments, so middleware can meter traffic that never becomes a message.- Benchmarks gained
--mode latency, which measures real round-trip times at pipeline depth 1 and reports p50/p90/p99/p99.9.
Breaking changes
WsMessage.Datais only valid for the duration of the handler. It points into a buffer the connection reuses for the next frame. Anything that outlives the handler must copy it (msg.Data.ToArray()).msg.Textis unaffected.- Handshakes that used to be accepted are now rejected: non-GET methods, HTTP/1.0, a missing
Host, aSec-WebSocket-Keythat is not 16 base64-decoded bytes, duplicate singleton headers, and header lines containing bare CR/LF. NetworkSessionGroup.RemoveFromAllis terminal for that session. Rejoining afterwards is ignored, which is what stops disconnect handlers from resurrecting dead sessions. UseRemoveper group to take a live session out of its rooms.WsUpgradeContext.AcceptSubprotocolthrowsArgumentExceptionfor a value the client did not offer or one that is not a valid token.- Rate limiting is stricter: a sliding window by default, and control frames and fragments now
consume budget. Set
SlidingWindow = falseandCountControlFrames = falsefor the old accounting. CloseAsynccan take longer. It drains queued data (up to 5 s) and, when this endpoint starts the closing handshake, waits for the peer's Close frame (CloseTimeout, 5 s). SetCloseTimeouttoTimeSpan.Zeroto drop TCP immediately.DisconnectAsync/DisposeAsyncon the clients block until the receive loop has finished, so a successful return now means the connection really is gone.WsPerMessageDeflate.Decompressrequires a maximum output size, andParseServerResponsethrowsWsProtocolExceptionfor a server response the client cannot honor.- Compression window-bits options are advisory only.
DeflateStreamcannot honor them, so rather than advertising a value it would ignore, the library declines offers that require a smaller server window. wss://connections now honorMaxPendingSendBytes/MaxPendingReceiveBytes. Applications that unknowingly relied on the 64 KB default will see different backpressure behavior.