Skip to content

Add compression support in WebSocket #31088

Closed

Description

@zlatanov

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

  1. davidfowl commented on Oct 7, 2019

    @davidfowl
    Member

    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?

  2. zlatanov commented on Oct 7, 2019

    @zlatanov
    ContributorAuthor

    @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

    new WebSocketCompressionOptions
    {
        ServerNoContextTakeover = true
    }

    than to write

    new WebSocketCompressionOptions( serverNoContextTakeover: true );

    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.

  3. davidsh commented on Oct 7, 2019

    @davidsh
    Contributor

    @dotnet/ncl

  4. scalablecory commented on Oct 7, 2019

    @scalablecory
    Contributor

    Would this still be usable if it were less configurable? What do we get by exposing the LZ77 window size?

  5. zlatanov commented on Oct 8, 2019

    @zlatanov
    ContributorAuthor

    @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:

    Method Window Bits Allocated native memory
    Inflate 8 8424 B
    Inflate 9 9448 B
    Inflate 10 11497 B
    Inflate 11 15592 B
    Inflate 12 23786 B
    Inflate 13 40177 B
    Inflate 14 72937 B
    Inflate 15 138472 B

    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.

    Method WindowBits Allocated native memory
    Deflate 8 139240 B
    Deflate 15 268264 B

    The benchmark bellow is running in the default memory level 2.

    Method WindowBits Allocated native memory
    Deflate 8 10216B
    Deflate 15 139240 B

    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.

  6. karelz commented on Dec 17, 2019

    @karelz
    Member

    @danmosemsft any idea why it was added to the Project ".NET Core impacting internal partners"?

  7. davidfowl commented on Dec 18, 2019

    @davidfowl
    Member

    @karelz that's where it most recently came up.

  8. zlatanov commented on Dec 18, 2019

    @zlatanov
    ContributorAuthor

    @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?

  9. davidfowl commented on Dec 18, 2019

    @davidfowl
    Member

    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?

  10. zlatanov commented on Dec 18, 2019

    @zlatanov
    ContributorAuthor

    @davidfowl Definitely.

  11. karelz commented on Dec 18, 2019

    @karelz
    Member

    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?

  12. davidfowl commented on Dec 18, 2019

    @davidfowl
    Member

    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.

    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).

    @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?

    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.

  13. transferred this issue fromdotnet/corefxon Feb 1, 2020
  14. added this to the 5.0 milestone on Feb 1, 2020
  15. davidfowl commented on Apr 15, 2020

    @davidfowl
    Member

    @zlatanov Are you still going to take this on?

    cc @BrennanConroy

  16. 62 remaining items

  17. CarnaViire commented on Mar 22, 2021

    @CarnaViire
    Member

    @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 old SendAsync overload, where you don't have a flag, it will mean that compression is on. If we go with EnableCompression, 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.

  18. zlatanov commented on Mar 23, 2021

    @zlatanov
    ContributorAuthor

    I like the added proposal changes.

    I don't think if you invert the flag to EnableCompression is 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.

  19. danmoseley commented on Mar 23, 2021

    @danmoseley
    Contributor

    I defer to whatever API review says ☺

  20. added
    blockingMarks issues that we want to fast track in order to unblock other important work
    on Mar 29, 2021
  21. bartonjs commented on Mar 30, 2021

    @bartonjs
    Member

    Video

    • We discussed whether the DisableCompression should be Enable or Disable, and agree Disable makes sense as a per-message option
    • The WebSocketMessageFlags enum should name the zero member (None, Default, etc)
    • Consider defaulting the WebSocketMessageFlags parameter in SendAsync
    • We're OK with the new "Dangerous" prefix, so long as it's really expected to be rarely used -- don't dilute the meaning of "Dangerous".
      • Ensure that there's consistency or followup action for higher level APIs that may be doing similar things, such as SignalR/ASP.NET.
    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);
        }    
    }
  22. added
    api-approvedAPI was approved in API review, it can be implemented
    and removed
    api-ready-for-reviewAPI is ready for review, it is NOT ready for implementation
    on Mar 30, 2021
  23. CarnaViire commented on Apr 8, 2021

    @CarnaViire
    Member

    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 WebSocketMessageFlags parameter in SendAsync
    We don't think None will be a good default. It seems likely that you're either calling EndOfMessage every time, or are calling it some of the time... but definitely not never. EndOfMessage is also not a good default, because then it will be counter-intuitive to call SendAsync explicitly with None to 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.

  24. danmoseley commented on Apr 8, 2021

    @danmoseley
    Contributor

    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 ?

  25. geoffkizer commented on Apr 8, 2021

    @geoffkizer
    Contributor

    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 ?

    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.

  26. geoffkizer commented on Apr 8, 2021

    @geoffkizer
    Contributor

    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.

  27. added a commit that references this issue on Apr 28, 2021
    2c3eb64
  28. ghost removed
    in-prThere is an active PR which will close this issue when it is merged
    on Apr 28, 2021
  29. ghost locked as resolved and limited conversation to collaborators on May 28, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    api-approvedAPI was approved in API review, it can be implementedarea-System.NetblockingMarks issues that we want to fast track in order to unblock other important work

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions