Repository navigation
Add compression support in WebSocket #31088
Description
Adds support for RFC 7692 "compression extensions for websocket".
API Proposal
// new
public sealed class WebSocketCreationOptions
{
// taken from existing WebSocket.CreateFromStream params.
public bool IsServer { get; set; }
public string? SubProtocol { get; set; }
public TimeSpan KeepAliveInterval { get; set; }
// new
// turns the feature on.
// consider: instead of multiple below properties on options class,
// make a new DeflateCompressionOptions class and if null it is turned off.
public bool UseDeflateCompression { get; set; } = false;
// configures desired client window size (larger window = better compression, more memory usage)
public int ClientMaxDeflateWindowBits { get; set; } = 15;
// controls if the window is re-used between subsequent messages,
// or if each message starts with a blank window.
// memory vs compression tradeoff again.
// might have a better name, "ClientPersistDeflateContext" etc.
public bool ClientDeflateContextTakeover { get; set; } = true;
public int ServerMaxDeflateWindowBits { get; set; } = 15;
public bool ServerDeflateContextTakeover { get; set; } = true;
}
// existing
// this is set in ClientWebSocket.Options before calling Connect() against a URL.
public sealed class ClientWebSocketOptions
{
// new, same as above.
public bool UseDeflateCompression { get; set; } = false;
public int ClientMaxDeflateWindowBits { get; set; } = 15;
public bool ClientDeflateContextTakeover { get; set; } = true;
public int ServerMaxDeflateWindowBits { get; set; } = 15;
public bool ServerDeflateContextTakeover { get; set; } = true;
}
// existing
public abstract class WebSocket
{
// existing
public static WebSocket CreateFromStream(Stream stream, bool isServer, string? subProtocol, TimeSpan keepAliveInterval);
// new
public static WebSocket CreateFromStream(Stream stream, WebSocketCreationOptions options);
}Original Request
AB#1118550
See discussion here #20004.
At the moment the WebSocket doesn't support per-message deflate (see https://tools.ietf.org/html/rfc7692#section-7). Adding support for it in the BCL will mean that people (myself including) will no longer resort to implementing custom WebSockets in order to use it.
Proposed API
/// <summary>
/// Options to enable per-message deflate compression for <seealso cref="WebSocket" />.
/// </summary>
public sealed class WebSocketCompressionOptions
{
/// <summary>
/// This parameter indicates the base-2 logarithm of the LZ77 sliding window size of the client context.
/// Must be a value between 8 and 15 or -1 indicating no preferences. The default is -1.
/// </summary>
public int ClientMaxWindowBits { get; set; } = -1;
/// <summary>
/// When true, the client informs the peer server of a hint that even if the server doesn't include the
/// "client_no_context_takeover" extension parameter in the corresponding
/// extension negotiation response to the offer, the client is not going to use context takeover. The default is false.
/// </summary>
public bool ClientNoContextTakeover { get; set; }
/// <summary>
/// This parameter indicates the base-2 logarithm of the LZ77 sliding window size of the server context.
/// Must be a value between 8 and 15 or -1 indicating no preferences. The default is -1.
/// </summary>
public int ServerMaxWindowBits { get; set; } = -1;
/// <summary>
/// When true, the client prevents the peer server from using context takeover. If the peer server doesn't use context
/// takeover, the client doesn't need to reserve memory to retain the LZ77 sliding window between messages. The default is false.
/// </summary>
public bool ServerNoContextTakeover { get; set; }
}
public sealed class ClientWebSocketOptions
{
/// <summary>
/// Instructs the <seealso cref="ClientWebSocket" /> to try and negotiate per-message compression.
/// </summary>
public WebSocketCompressionOptions Compression { get; set; }
}
public enum WebSocketOutputCompression
{
/// <summary>
/// Enables output compression if the underlying <seealso cref="WebSocket" /> supports it.
/// </summary>
Default,
/// <summary>
/// Suppresses output compression for the next message.
/// </summary>
SuppressOne,
/// <summary>
/// Suppresses output compression.
/// </summary>
Suppress
}
public abstract class WebSocket
{
/// <summary>
/// Instructs the socket to compress (or not to) the messages being sent when
/// compression has been successfully negotiated.
/// </summary>
public WebSocketOutputCompression OutputCompression { get; set; }
public static WebSocket CreateFromStream(Stream stream, bool isServer, string subProtocol, TimeSpan keepAliveInterval, WebSocketCompressionOptions compression);
}Rationale and Usage
The main drive behind the API is that we should not introduce any breaking changes. WebSockets already built will work as is. This is why I suggest we add new CreateFromStream method with WebSocketCompressionOptions parameter instead of adding it to the existing one.
There are a few options built in the WebSocket compression protocol that are considered advance use - controlling the size of the LZ77 sliding window, context takeover. We could easily hide them and choose reasonable defaults, but I think there are good use cases for them and as such we should expose them. See this blog post for good example of their usage: https://www.igvita.com/2013/11/27/configuring-and-optimizing-websocket-compression/.
The usage of the WebSocket would not change at all. By default a WebSocket created with compression options would compress all messages if the connection on the other end supports it. We introduce OutputCompression property to allow explicit opt out. The property has no effect if compression is not supported for the current connection.
Here is example of how we would disable compression for specific messages:
var socket = GetWebSocket();
// Disable compression for the next message only
socket.OutputCompression = WebSocketCompressionOptions.SuppressOne;
await socket.SendAsync(...);Here is example for WebSocketClient:
var client = new WebSocketClient();
// Indicate that we want compression if server supports it
client.Options.Compression = new WebSocketCompressionOptions();
await client.ConnectAsync(...);
// Same as before. If compression is enabled it will be performed automatically.
client.SendAsync(...);
// If we need to explicitly disable compression for a time or specific messages
client.OutputCompression = WebSocketOutputCompression.Suppress;
// Send one or more messages
// ...
// Restore default compression state
client.OutputCompression = WebSocketOutputCompression.Default; Additional work will be required in AspNetCore repository and more specifically WebSocketMiddleware to light up the compression feature.
Activity
davidfowl commentedon Oct 7, 2019
Something rubs me the wrong way about being able to mutate the options on the object after it's started on a per message basis. What's the rationale for that?
zlatanov commentedon Oct 7, 2019
@davidfowl No particular reason. The options would actually be used only once in the WebSocket (in the ctor), no reference to them would be kept. In that regard they could very well be a struct, although it would have to be nullable in WebSocketClientOptions.
I find it more readable to be able to write
than to write
With that in mind, this is only an opinion. If you think immutable class or struct is better for the case, I will change it.
davidsh commentedon Oct 7, 2019
@dotnet/ncl
scalablecory commentedon Oct 7, 2019
Would this still be usable if it were less configurable? What do we get by exposing the LZ77 window size?
zlatanov commentedon Oct 8, 2019
@scalablecory The websocket would be very usable without it, but servers with high number of concurrent connections or clients with low memory requirements will have problems with any defaults we come up with.
The websocket will have to keep 2 separate native memory buffers for inflate and deflate. I've collected benchmark traces for both with the different options to illustrate the point.
Here is how the allocations look for inflate:
Here is how the allocations look for deflate. Here however we have another option to consider that I've not exposed - the memory and compression level and of the deflater (i.e. fast vs best).
The benchmark bellow is running in the default memory level 8 (max is 9) used by DeflateStream for best compression.
The benchmark bellow is running in the default memory level 2.
As you can see the memory footprint for a single WebSocket can range anywhere between 18KB and 400KB. I cannot see how we can come up with any defaults that would be considered universally good enough for any web server or client.
karelz commentedon Dec 17, 2019
@danmosemsft any idea why it was added to the Project ".NET Core impacting internal partners"?
davidfowl commentedon Dec 18, 2019
@karelz that's where it most recently came up.
zlatanov commentedon Dec 18, 2019
@karelz I see that you've changed the milestone to Future. Does it mean you will not consider this proposal for the 5.0 release?
davidfowl commentedon Dec 18, 2019
I think it's worth doing if we can flesh out a design and @zlatanov contributes. @zlatanov do you still want to get this in for 5.0?
zlatanov commentedon Dec 18, 2019
@davidfowl Definitely.
karelz commentedon Dec 18, 2019
Milestone reflects our intent and priority from our side. It can change if more info appears. Nothing prevents anyone to get Future issues done in specific release, incl. 5.0. We take any high quality contributions.
API design is tricky though in general and requires non-trivial investment from our side first. My understanding was that the original API proposal here seemed to be too complicated to the person driving our API reviews -- @scalablecory can provide more feedback / impressions he has from the API.
@davidfowl what did you mean by "that's where it most recently came up."? Dan sent me offline info and it seems that it came from ASP.NET - maybe we can connect those dots also publicly here?
davidfowl commentedon Dec 18, 2019
👍
Sure and it has to be costed. That's basically what @zlatanov is looking for, design feedback following our typical API review process. We should spend a little time discussing it to see if it is complicated and make a decision. I don't think we have enough information to make a call yet (on the API).
It didn't, it came from another team internally at Microsoft. This issue has come up in the past filed by customers on the ASP.NET repository and we opened an issue here https://github.com/dotnet/corefx/issues/15430 a long time ago.
davidfowl commentedon Apr 15, 2020
@zlatanov Are you still going to take this on?
cc @BrennanConroy
62 remaining items
CarnaViire commentedon Mar 22, 2021
@danmoseley our reason for naming it
DisableCompression/NoCompress/whatever-opt-out was that you'd use that flag rarely. If you decide to use compression, you would want to use it for most messages, and only turn it off if you want to send a secret once in a while. If you send a lot of secrets, it's better not to use compression at all... And if you use the oldSendAsyncoverload, where you don't have a flag, it will mean that compression is on. If we go withEnableCompression, the compression will be off by default, so we'll be forcing to use a new overload everywhere. It may not be such a bad thing though.zlatanov commentedon Mar 23, 2021
I like the added proposal changes.
I don't think if you invert the flag to
EnableCompressionis a good choice. I think that it will pollute the existing code too much and it will give the impression that if I send a message with this flag on, the message will be compressed. This is not the case because it also depends whether compression has been enabled in the first place. This double enabling seems more confusing than having to opt out.danmoseley commentedon Mar 23, 2021
I defer to whatever API review says ☺
bartonjs commentedon Mar 30, 2021
Video
namespace System.Net.WebSockets { public sealed class WebSocketCreationOptions { public bool IsServer { get; set; } public string? SubProtocol { get; set; } public TimeSpan KeepAliveInterval { get; set; } - public WebSocketDeflateOptions? DeflateOptions { get; set; } = null; + public WebSocketDeflateOptions? DangerousDeflateOptions { get; set; } = null; } public partial class ClientWebSocketOptions { - public WebSocketDeflateOptions? DeflateOptions { get; set; } = null; + public WebSocketDeflateOptions? DangerousDeflateOptions { get; set; } = null; } + [Flags] + public enum WebSocketMessageFlags + { + None = 0, + + // taken from existing WebSocket.SendAsync params + EndOfMessage = 1, + + // new + DisableCompression = 2 + } public partial class WebSocket { + public virtual ValueTask SendAsync(ReadOnlyMemory<byte> buffer, WebSocketMessageType messageType, WebSocketMessageFlags messageFlags, CancellationToken cancellationToken = default); } }CarnaViire commentedon Apr 8, 2021
We've discussed the things left for consideration.
1) Whether the "Dangerous" prefix might be overused so it will lose its purpose
We agreed on keeping the dangerous prefix. We think the danger is completely non-obvious so it's good to call it out. We don't think enough people will use it to dilute the prefix's meaning.
2) Defaulting
WebSocketMessageFlagsparameter inSendAsyncWe don't think
Nonewill be a good default. It seems likely that you're either callingEndOfMessageevery time, or are calling it some of the time... but definitely not never.EndOfMessageis also not a good default, because then it will be counter-intuitive to callSendAsyncexplicitly withNoneto state the message will have a continuation. That's why we believe it makes sense to ask the caller to always specify the flag, no defaulting.If anyone sees any issues with our decisions, please let us know.
danmoseley commentedon Apr 8, 2021
Is it possible that there might in future be a solid mitigation to this dangerousness? Eg., a form of compression that pads to a fixed length. In that case, might we have a WebSocketDeflateOptions member that wasn't dangerous, and yet was accessed with DangerousDeflateOptions ?
geoffkizer commentedon Apr 8, 2021
I doubt it. For compression to be useful, it has to compress to something smaller than the original. If it does, then it seems like it's still subject to a CRIME attack.
geoffkizer commentedon Apr 8, 2021
To be clear, the general mitigation is to ensure you don't use the same compression context to compress data from different sources, at least one of which is untrusted. Unfortunately it's not easy to figure out how to enforce that generally.
WebSocket Compression (#49304)