Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[API Proposal]: Add APIs to WebSocket which allow it to be read as a Stream #111217

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
christothes opened this issue Jan 8, 2025 · 16 comments
Open
Labels
api-needs-work API needs work before it is approved, it is NOT ready for implementation area-System.Net
Milestone

Comments

@christothes
Copy link

christothes commented Jan 8, 2025

EDITED by @stephentoub 4/7/2025 to add API for review:

+public sealed class WebSocketStream : Stream
+{
+    public WebSocketStream(WebSocket webSocket, bool ownsWebSocket = false);
+    public WebSocket WebSocket { get; }
+
+    ... // relevant Stream overrides
+}

Open questions:

  • Name and polarity of the boolean. NetworkStream uses ownsSocket = false, but while it's the closest in relation to this class, it's also fairly unique i this approach. Most other .NET types use leaveOpen = false.
  • There's a possible further designed expansion, where you could create a Stream to represent writing out a single message in parts, in which case all writes on the stream would be with EndOfMessage:false and then closing the stream would output an empty message with EndOfMessage:true. If we add that subsequently, how would we want it to show up? Should we instead have a factory method WebSocketStream.Create, and then later add a WebSocketStream.CreateMessage?

Background and motivation

Utilizing WebSockets is a convenient approach to writing real-time audio processing code for ASP.NET applications. One such scenario is implementing a real-time conversation with Open AI.

OpenAI's real-time API SendInputAudioAsync accept a Stream as input which leaves it up to the developer to write a custom Stream implementation that reads from an underlying WebSocket. It would be a nice enhancement to the WebSocket APIs if one could wrap read operations in a Stream.

API Proposal

public class WebSocket
{
    public Stream AsStream();
}

API Usage

using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
using RealtimeConversationSession session = await InitSession(realtime);
// <...>
using var stream = webSocket.AsStream();
await session.SendInputAudioAsync(stream);

Alternative Designs

No response

Risks

WebSocket doesn’t provide synchronous methods for wire-based operations, so all of the Stream sync APIs (including Dispose, which presumably would need to not just Dispose the WebSocket but also CloseAsync it) would be sync-over-async.

@christothes christothes added the api-suggestion Early API idea and discussion, it is NOT ready for implementation label Jan 8, 2025
@dotnet-policy-service dotnet-policy-service bot added the untriaged New issue has not been triaged by the area owner label Jan 8, 2025
Copy link
Contributor

Tagging subscribers to this area: @dotnet/ncl
See info in area-owners.md if you want to be subscribed.

@MihaZupan
Copy link
Member

MihaZupan commented Jan 9, 2025

It's an interesting idea, though its use seems limited only to cases where you know that only the binary content is being transmitted (e.g. only the audio, no control data, no extra framing).
There's also the question of what happens with close messages -- does the user not care about the data, who's responsible for responding to them, do you send them during disposal.

I can see it being useful in cases where you just need an opaque Stream and happen to be using WebSocket as the transport.

Sample code if someone needed such a Stream could be something like this (untested):

public sealed class WebSocketStream : Stream
{
    private readonly WebSocket _webSocket;

    public WebSocketStream(WebSocket webSocket) => _webSocket = webSocket;

    public override bool CanRead => _webSocket.State is WebSocketState.Open or WebSocketState.CloseSent;
    public override bool CanWrite => _webSocket.State is WebSocketState.Open or WebSocketState.CloseReceived;
    public override bool CanSeek => false;

    public override void Flush() { }
    public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask;

    public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
        ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();

    public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
        WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();

    public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
    {
        ValueWebSocketReceiveResult result = await _webSocket.ReceiveAsync(buffer, cancellationToken);

        if (result.MessageType != WebSocketMessageType.Binary)
        {
            if (result.MessageType == WebSocketMessageType.Close)
            {
                await _webSocket.SendAsync(ReadOnlyMemory<byte>.Empty, WebSocketMessageType.Close, endOfMessage: true, cancellationToken);
                return 0;
            }

            throw new Exception("Expected binary messages");
        }

        return result.Count;
    }

    public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default) =>
        _webSocket.SendAsync(buffer, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken);

    public override ValueTask DisposeAsync()
    {
        Dispose(true);
        return default;
    }

    protected override void Dispose(bool disposing) => _webSocket.Dispose();

    public override int Read(byte[] buffer, int offset, int count) => ReadAsync(buffer, offset, count).GetAwaiter().GetResult();
    public override void Write(byte[] buffer, int offset, int count) => WriteAsync(buffer, offset, count).GetAwaiter().GetResult();

    public override long Length => throw new NotSupportedException();
    public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
    public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
    public override void SetLength(long value) => throw new NotSupportedException();
}

@MihaZupan
Copy link
Member

MihaZupan commented Jan 16, 2025

Triage: We see the value and it should be a relatively low amount of work to implement. Even a simple GH search shows many users implementing similar wrappers themselves, moving to 10.0.

We'll have to figure out a default for how we handle things like close messages, but it should otherwise be straightforward.

@MihaZupan MihaZupan removed the untriaged New issue has not been triaged by the area owner label Jan 16, 2025
@MihaZupan MihaZupan added this to the 10.0.0 milestone Jan 16, 2025
@CarnaViire
Copy link
Member

I think we can also take inspiration from the NetworkStream, which does similar thing for a Socket

@antonfirsov antonfirsov self-assigned this Jan 21, 2025
@stephentoub
Copy link
Member

stephentoub commented Apr 5, 2025

@antonfirsov, I see you assigned this to yourself in January. Are you working on it? If not, I'd like to push it forward.

@stephentoub stephentoub added api-ready-for-review API is ready for review, it is NOT ready for implementation and removed api-suggestion Early API idea and discussion, it is NOT ready for implementation labels Apr 8, 2025
@bartonjs
Copy link
Member

bartonjs commented Apr 8, 2025

Video

  • Changed the constructor to a triad of factory methods.
    • That both enables the multiple-frame-single-message scenario and should give the caller pause as to which mode they want.
namespace System.Net.WebSockets;

public class WebSocketStream : Stream
{
    internal WebSocketStream();

    public WebSocket WebSocket { get; }

    public static WebSocketStream Create(WebSocket webSocket, bool ownsWebSocket = false);
    public static WebSocketStream CreateWritableMessageStream(WebSocket webSocket);
    public static WebSocketStream CreateReadableMessageStream(WebSocket webSocket);

    ... // relevant Stream overrides
}

@bartonjs bartonjs added api-approved API was approved in API review, it can be implemented and removed api-ready-for-review API is ready for review, it is NOT ready for implementation labels Apr 8, 2025
@rynowak
Copy link
Member

rynowak commented Apr 8, 2025

WebSocket doesn’t provide synchronous methods for wire-based operations, so all of the Stream sync APIs (including Dispose, which presumably would need to not just Dispose the WebSocket but also CloseAsync it) would be sync-over-async.

Would you consider an optional parameter to throw instead? For server scenarios this is risky. I'd rather know we coded a perf bug right away.

@stephentoub
Copy link
Member

WebSocket doesn’t provide synchronous methods for wire-based operations, so all of the Stream sync APIs (including Dispose, which presumably would need to not just Dispose the WebSocket but also CloseAsync it) would be sync-over-async.

Would you consider an optional parameter to throw instead? For server scenarios this is risky. I'd rather know we coded a perf bug right away.

I don't think we have anything like that anywhere else in .NET. You could of course wrap the resulting stream in one that just delegates to the wrapped one for anything other than the synchronous methods and has the synchronous methods all throw.

@MihaZupan
Copy link
Member

MihaZupan commented Apr 8, 2025

Kestrel does have an opt-in AllowSynchronousIO flag, but sadly that wouldn't help catch mistakes here since we're not (can't) propagating the calls as sync.

@CarnaViire
Copy link
Member

CarnaViire commented Apr 8, 2025

@stephentoub Thanks for keeping this moving.

Unfortunately, we didn’t get a chance to share our feedback on the proposal in time.

The message below might now be slightly outdated, as I still need to catch up on the API review recording. I’ll post it anyway and plan to follow up tomorrow with any updates or further thoughts.


@antonfirsov did some research of the existing community implementations. The results were:

  • Write:
    • Message type:
      • almost all have Binary hardcoded
      • there are exceptions (e.g. configurable)
    • EOM flag:
      • most use EOM=true on each write
      • some use EOM=false and end the message on Dispose
  • Read:
    • Receives per read:
      • most issue a single receive per read with the user's buffer
      • a few of them attempt to fill the buffer with multiple receives
    • EOM flag:
      • // no data from @antonfirsov here; assuming most ignore it, but there definitely are at least a few exceptions, e.g. see WCF implementation below
  • Dispose:
    • community implementations all vary in this, doing random stuff: fully gracefully closing, closing with timeout, fully aborting, half-aborting, leaving open, etc.

It's also worth taking a closer look at the WCF WebSocketStream implementation that has an interesting approach:

  • Write:
    • configurable msg type (ctor param)
    • EOM=false ("1 msg per stream" approach)
  • Read:
    • single receive per read
    • stops reading after receiving EOM ("1 msg per stream" approach)
  • Dispose:
    • sends EOM=true
    • if not received yet, drains until EOM received
    • configurable timeout for the whole operation (ctor param)
    • WebSocket is left open, and can be reused for the next instance of the stream
Some raw notes under cut

useful things:
they have parameters like

  • WebSocketMessageType outgoingMessageType
  • TimeSpan closeTimeout

and the fun stuff:
yes, they are using a stream for 1 big message: on write they send EOM=false and on stream disposal they send EOM=true..... BUT! they can REUSE the websocket, so they can create a SECOND stream, which will send a SECOND message!! this is how they can do the framing without having any direct access to WS frames

I think we need to have some basic options from the start

  • smth like ownsWebSocket / closeOnDispose
  • closeTimeout for the case when we do close on dispose (so we wouldn't end up hanging on disposal)
  • whether to send EOM with every send
  • and outgoing msg type

all of them can have some reasonable defaults; also all of them don't add any complexity on top of the thin wrapper IMO

by default ownsWebSocket should be true I think (especially if not exposed) so we would need to implement close logic anyway, that's why I say this flag also won't add complexity

dispose would do a graceful close handshake, the paradigm is that the code

using (Stream s = CreateMyStream())
{
    // write smth to the stream
}

should always result in a graceful EOS no matter what the underlying stream is (e.g. file fully written and properly closed for a file stream)

read shutdown can be abortive, but write shutdown must be graceful

I think NetworkStream does this as well (dispose calls socket.Shutdown(Both) IIRC and not a Close)

Community implementations all vary in this, doing random stuff
that's why we'd better give them ability to choose
e.g. if they set the close timeout to 0, then unless the peer has sent us a close before, we'd just abort without waiting for peer's response to close.
or if they set ownsWebSocket to false, then the stream will not send a close msg and the user's using WebSocket ws = ... will later abort the ws when the code block finishes

As a datapoint:
we don't have a timeout for shutdown in QuicStream. We always wait for the transport to tell us "shutdown completed". BUT the transport event is much much more reliable than ws waiting for a peer to send a close msg....

that is, in Quic, shutdown completed will ALWAYS happen, the only thing is that it happens asynchronously. but in WebSocket case, we have no control and no estimates for the peer's reply.

I think it makes sense to expose the options covering the highlighted differences as a ctor/factory method parameter, for example:

namespace System.Net.WebSockets;

public class WebSocketStreamOptions
{
    WebSocketMessageType OutgoingMessageType { get; set; } = WebSocketMessageType.Binary;

    bool WriteSingleMessagePerStream { get; set; } = false; // EOS is outgoing EOM
    bool ReadSingleMessagePerStream { get; set; } = false; // incoming EOM is EOS

    bool OwnsWebSocket { get; set; } = false;
    TimeSpan DisposeTimeout { get; set; } = TimeSpan.FromSeconds(1); // reflecting the hardcoded timeout from https://github.com/dotnet/runtime/blob/2bd17019c1c01a6bf17a2de244ff92591fc3c334/src/libraries/System.Net.WebSockets/src/System/Net/WebSockets/ManagedWebSocket.cs#L1153
}

public class WebSocketStream : Stream
{
    internal WebSocketStream();

    public WebSocket WebSocket { get; }

    public static WebSocketStream Create(WebSocket webSocket, bool ownsWebSocket = false);
    public static WebSocketStream Create(WebSocket webSocket, WebSocketStreamOptions options);

    ... // relevant Stream overrides
}

@stephentoub
Copy link
Member

stephentoub commented Apr 9, 2025

@CarnaViire, with the exception of the dispose draining / timeout, isn't most of that covered by the approved API?

It is not hard to implement this on top of WebSocket, so this doesn't need to be everything to everyone. It should address the 90% case simply and well, and I believe the approved proposal does that, no? For something super rare, like wanting to write out text instead of binary, a developer can still use WebSocket directly or layer on their own stream.

WebSocketStreamOptions

Lumping all those options together leads to strange combinations. If WriteSingleMessagePerStream is true, for example, then I would assume the stream doesn't / shouldn't own the websocket, because disposal is then about ending a message, not about closing the underlying websocket. Yet a developer is still presented with the option of setting OwnsWebSocket to true, and is still presented with a disposal timeout that'd never be used. Similarly, having a duplex stream that's somehow responsible for both reading a single message and writing a single message is a confusing mix of concepts. If you instead separate those out into separate methods, you end up with the approved proposal.

@CarnaViire
Copy link
Member

Lumping all those options together leads to strange combinations. If WriteSingleMessagePerStream is true, for example, then I would assume the stream doesn't / shouldn't own the websocket, because disposal is then about ending a message, not about closing the underlying websocket.

I don't think these combinations are that weird though..?

  • Re: WriteSingleMessagePerStream=true + OwnsWebSocket=true

    • if I'm uploading a single file with this stream, for example. I'm sending it as a series of writes with eom=false, and in the end I want to say I'm done completely, i.e. finish the message and properly close the websocket at the same time. also a request-response-like approach might want to do the same. Even for the case of multiple messages written as 1-per-stream manner, it is handy to be able to complete the writing loop just by setting a parameter in a final message -- "fin flag" analogy -- why would I have to touch the webSocket manually if I know for sure that the stream knows how to close on its own.
  • Re: WriteSingleMessagePerStream=true + OwnsWebSocket=false + disposal timeout

    • the dispose timeout does not have to be exclusively a Close timeout. it applies to all operations that are within dispose, be it SendAsync or ReceiveAsync. This is what WCF stream is doing (confusingly, they named it "closeTimeout" but it is used when sending EOM and draining the last incoming bytes.
  • Re: duplex stream

    • if I have a request-response approach, where I have to wait for the response, and only after I will be free to send the next request -- why should I have to make two allocations each time, and manually "synchronize" the pairs, if I can have one just one bidirectional stream? FWIW, NetworkStream is my main inspiration and it's bidirectional, and SslStream too.

The Options class approach is also used quite a lot for websockets. And it is handy when the options set is expanded. If we were to add something applicable to all the three factory methods, we'd need to add more and more overloads...

It is not hard to implement this on top of WebSocket

that's exactly why I feel it is important to expose configurability. this is a convenience class, so it should be as convenient as it can IMO...

with the exception of the dispose draining / timeout

I believe the specifics of the dispose behavior is an important thing, given that the community implementations are all doing different things there...

I'm not against the factory methods in the proposal, but it just feels that if we'd ever need to expand, we'll end up adding the overload with some kind of an options class anyway.

@stephentoub
Copy link
Member

FWIW, NetworkStream is my main inspiration and it's bidirectional, and SslStream too

Yes, and those aren't per "message". I agree bidirectional is the main use case, that's what the Create method is for.

Frankly I'd be happy if we just did the Create(WebSocket, bool) overload. Maybe that's the answer. If in the future there's real demand for configuration, another configurable overload can be exposed.

if I'm uploading a single file with this stream, for example

Why must that be a single message? Keeping in mind, as Stephen called out yesterday, that JS in the browser doesn't read partial messages.

if I have a request-response approach, where I have to wait for the response, and only after I will be free to send the next request -- why should I have to make two allocations each time, and manually "synchronize" the pairs, if I can have one just one bidirectional stream?

For single message? How do you communicate you're done writing the outbound message while still being able to read the inbound message? All that exists in that case to end the message is Dispose, and if you call that, it'd also block until the full read was drained or timed out, and you wouldn't be able to read the rest. Using the same instance for both reading one message and writing one message does not make sense to me.

@CarnaViire
Copy link
Member

How do you communicate you're done writing the outbound message while still being able to read the inbound message? All that exists in that case to end the message is Dispose, and if you call that, it'd also block until the full read was drained or timed out, and you wouldn't be able to read the rest.

Touché 😄
This could only work the other way around (read, then write), but I agree it doesn't make much sense to single this out.

Frankly I'd be happy if we just did the Create(WebSocket, bool) overload.

I believe the unidirectional single message case is important enough. It allows for a seamless integration with e.g. JsonSerializer.SerializeAsync(Stream, ...) and JsonSerializer.DeserializeAsync(Stream, ...) (example found in azure-web-pubsub-bridge sample)


Exploring Azure WebSockets further led me to another sample "Streaming logs using json.webpubsub.azure.v1 subprotocol and native websocket libraries" which shows that the Azure's subprotocol json.webpubsub.azure.v1 expects json text messages to be sent with the Text opcode.

This makes me wondering if we should include the message opcode parameter, at least to the CreateWritableMessageStream to enable such cases. WDYT @stephentoub?

var ws = new ClientWebSocket();
ws.Options.AddSubProtocol("json.webpubsub.azure.v1");
await ws.ConnectAsync(url, default);

while (ReadNextMessage() is {} message)
{
    using var wsStream = WebSocket.CreateWritableMessageStream(ws, WebSocketMessageType.Text);
    await JsonSerializer.SerializeAsync(wsStream, message);
}

@stephentoub
Copy link
Member

This makes me wondering if we should include the message opcode parameter, at least to the CreateWritableMessageStream to enable such cases. WDYT

Seems ok.

What is it you'd like the API shape to be then?

@CarnaViire
Copy link
Member

CarnaViire commented Apr 17, 2025

We had an offline discussion to align on next steps for the WebSocketStream API.

TL;DR: We agreed on another iteration to refine the API, focusing on key scenarios and essential parameters, clarifying default disposal behavior, and determining necessary timeouts. Further investigation and scenario analysis are planned before finalizing the proposal.


Discussion Summary

Team Alignment:

  • Agreed on another API iteration to better understand and cover key use cases.

API Goals:

  • Aim to support ~90% of practical scenarios; exact scope needs clarification.
  • Identify and investigate additional key scenarios (e.g., Azure PubSub, integration with JSON serialization).

Parameters and Configurability:

  • Balance configurability and simplicity; avoid extensive parameter lists.
  • Critical parameter missing: WebSocketMessageType.
  • Prefer property bag (options class) approach over multiple overloads to manage future parameter additions.
  • Current approach favors three distinct methods to avoid invalid parameter combinations (e.g., "ownsSocket" relevant only for duplex streams).

Disposal Behavior & Default Values:

  • Key issue: Decide default disposal behavior for single-message streams when message is partially read. Options include:
    1. Do nothing (risk malformed messages).
    2. Abort the WebSocket.
    3. Drain remaining message (with possible timeout considerations).
  • Investigate necessity and implications of implementing message draining (used previously in WCF).

Timeout Considerations:

  • Determine necessity of timeouts for draining incomplete messages and WebSocket close handshake.
  • Assess if current hardcoded timeout in existing implementations suffices.

Next Steps:

  • Conduct detailed scenario investigation (driven by user and integration needs).
  • Define the "90%" scenario clearly and update API accordingly.
  • Finalize API shape proposal and initiate review discussions on GitHub.
  • Prepare for potential offline discussions if online reviews do not converge.
More details under cut

Discussion Summary (Extended)

Team Discussion:

We had an offline discussion as a team and aligned on the need for another iteration of work and review for the API.

API Iteration:

Our goal is to cover 90% of the use cases, but we don't fully understand what this 90% entails yet. During the discussion, I brought up an important use case that we had failed to consider earlier, highlighting the need for more thorough investigations. We had a brief investigation before, but it needs to be deeper than that to ensure we cover all necessary aspects.

Parameter Agreement:

We have agreed that we don't want to have too many parameters for the API. This is a convenience API, so we need to find a good balance that provides just enough configurability without covering every single thing. The current state of the API lacks at least one important parameter, WebSocketMessageType. We need to assess the possibility of additional parameters being expanded in the future. If we need to add one more parameter in the next release, we would have to add it as an overload. Generally, we would like to avoid growing overloads for every new parameter. In such cases, we usually add a property bag (options class) that is passed to the method, so only the property bag grows and the signature of the method remains the same. However, my initial proposal to make these options too extensive was rejected because some of them don't work together. That's why the current iteration with three methods is preferential. For example, the "ownsSocket" parameter only makes sense for the bidirectional case.

Parameter Investigation:

We agreed that we need an investigation to determine which parameters should be included right now, which have the possibility to be added in the future based on demand, and which are too niche and should be implemented in their own stream. This is a convenience API, so it should be driven by scenarios. We need an investigation of the WebSocket usage scenarios, with a focus on integrations with other APIs and tools, as well as important use cases like Azure PubSub. Based on that, we can determine which scenarios represent the 90% and which we consider too niche. We should also consider cases where the end users didn't actually implement a stream, but adding it will be extremely beneficial. For example, integration with JSON serialization, which makes the code much more compact and clearer.

Default Values and Behaviors:

We will have to make decisions on the default values and behaviors for the things we don't expose as parameters. The most controversial is the dispose behavior. For duplex stream, we have the ownsWebSocket parameter, which determines whether or not the WebSocket close sequence is triggered on disposal. For single-message read or write stream, we agree that the WebSocket is not owned by that stream. However, if we didn't read the message until the end, what should happen?

Options:

  1. Do Nothing: The next reader can get an incomplete (malformed) message, similar to firing a cancellation token on a network stream.
  2. Abort: For example, MsQuic can only abort the whole read side. We need to check our implementation.
  3. Drain Until End of Message: This approach is strange as data is silently swallowed, and the message can be big. It seems it would need a timeout. But then, what to do if the timeout fires? Abort or ignore and just stop waiting?

Timeout Considerations:

The timeout is a question in itself because we can decide to expose it. Even though disposal behavior is an implementation detail, it can actually affect the API shape. The timeout is applicable both to the close handshake (note there is already a hardcoded "drain on close" timeout) and to the draining on single-message-read stream, if we decide to implement it. An open question is whether this draining is actually needed at all. It was present in the WCF WebSocketStream implementation, but we need to check the usages and potentially understand what it was needed for. We know that someone has a WCF WebSocketStream source copy in their code. It would be good to know the reason and whether this draining is used at all. If draining is not needed, and if we verify that there is already a timeout enforced over CloseAsync, this will make things much easier because it will save us the dispose-related parameters. No one seemed to complain about close for all this time, so the hardcoded timeout might work well enough.

To-Do Items:

  • I am to write this summary. ☑ Done
  • I (@CarnaViire) am to drive the additional scenario investigation.
  • We reach agreement as a team on what the 90% case is.
  • Based on this, I am to propose an updated API shape.
  • If @stephentoub, @MihaZupan, or @antonfirsov have additional thoughts or comments, they are more than welcome to post them, both now (if I missed something in the writeup) and as a reaction to the proposal.
  • We try to drive it to ready-for-review over GitHub early next week. If we see the online discussion is not converging, we will have one more offline team discussion. Note that Friday and Monday are holidays at least in CZ (most of the team) and UK (me), and maybe US (Stephen) too. I initially planned to have the GitHub discussion before next Tuesday, but next Tuesday is literally the next working day after today (Thursday, April 17). But we'll see. Maybe we can touch on it offline on Tuesday anyway, or maybe we'll manage to have some preliminary discussion on GitHub.

@stephentoub, @MihaZupan, @antonfirsov pls let me know if I failed to capture something, or if you had some additional thoughts since our discussion. Thanks!

@CarnaViire CarnaViire removed the api-approved API was approved in API review, it can be implemented label Apr 19, 2025
@CarnaViire CarnaViire added the api-needs-work API needs work before it is approved, it is NOT ready for implementation label Apr 19, 2025
@stephentoub stephentoub removed their assignment May 5, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
api-needs-work API needs work before it is approved, it is NOT ready for implementation area-System.Net
Projects
None yet
Development

Successfully merging a pull request may close this issue.

7 participants