Skip to content

.NET-to-JS stream proof of concept #32848

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

Closed
wants to merge 3 commits into from

Conversation

SteveSandersonMS
Copy link
Member

This is just an exploration, not a real PR. I think it may be @TanayParikh doing the real implementation, but I'm not sure if that's confirmed yet.

It is a bit complicated, but hopefully achieves what we need in terms of not pinning things in memory indefinitely on the .NET side. It's also very flexible on the JS side, as you can receive a ReadableStream which you can then easily convert asynchronously into an ArrayBuffer or you can convert into a Blob to be used as a response (e.g., for image contents).

The sequence of operations is:

  1. Developer creates a DotNetStreamReference to wrap some Stream
  2. Developer uses this as a param in .NET-to-JS interop, or a return value in JS-to-.NET interop. As soon as it's serialized into the JSON with some auto-generated ID, the JSRuntime base class instructs the hosting platform to supply the Stream to JS code under the same ID, by calling a protected virtual method BeginTransmittingStream.
  3. Within this method, the hosting platform is responsible for somehow sending a representation of that stream into JS, and calling DotNet.jsCallDispatcher.supplyDotNetStream(streamId, readableStream). The details of the transport, chunking, multiplexing, etc. are all left up to the hosting platform.
  4. The developer's JS-side code receives the DotNetStreamReference as an instance of a JS class that has a function stream() that returns a Promise<ReadableStream>, whose value is given by what is supplied in step 3 above

As you see, there's nothing on the .NET side that has to hold onto anything.

The way that step 3 works depends on the hosting platform. The most complex one is Blazor Server, which is what I've prototyped in this PR. Since SignalR only allows streaming to be initiated from the JS side, what it does is:

  1. Sends a message to JS saying Please request stream [id].
    • Since this is async, it has to stash the stream in .NET memory until that request arrives
    • We allow a maximum of just 10 seconds for the JS side to begin requesting the stream. If the JS side fails to do so fast enough, we remove the stream from the temporary in-memory dictionary and dispose it - this is an error case that shouldn't occur in normal apps.
  2. On the JS side, the code does SignalR stream-returning call, asking for that stream. It generates a JS ReadableStream instance and wires up the SignalR stream response to provide content for it. This becomes the stream instance returned to user code from step 4 above.
  3. Back on the .NET side, assuming the stream instance hasn't been evicted yet (i.e., as long as steps 1 and 2 completed in under 10 seconds), we then pump the Stream contents into the SignalR stream response as fast as we're allowed (which can take arbitrarily long), then finally closes it.

Open questions

  • Do we believe this is secure against hostile clients? What goes wrong if they try to slow down the transfer to a trickle - can they do so through backpressure? Is there any problem with stashing the stream for 10 seconds before disposing it if the client never actually requests it?
  • Is the error handling/reporting correct, given all the moving parts?
  • Do we need to do anything about infinite streams? As it stands, the .NET side would continue pumping the contents as fast as it can until the circuit eventually disconnects (which can take arbitrarily long).

Alternative considered

If we wanted, we could change the flow so that the .NET side pumps the entire stream contents across before the JS-side invocation occurs. Then the JS-side code could receive an ArrayBuffer instead of a stream.

TBH I'm not convinced that actually solves any of the tricky things here, and it loses the ability to actually read the stream data incrementally in JS.

Or even more aggressively, we could just pump out the entire stream as a byte[] in the initial SignalR message. This might simplify a lot of the moving parts, but would massively reduce the usefulness of this because (I think) it would no longer multiplex at all and would block the entire circuit from being interactive until the whole transfer completes.

@SteveSandersonMS SteveSandersonMS requested review from TanayParikh and a team May 19, 2021 16:49
async function getTheStream(streamRef) {
const data = await streamRef.arrayBuffer();
console.log(data.byteLength);
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an example of receiving the stream as a parameter.

const streamRef = await DotNet.invokeMethodAsync('BlazorServerApp', 'GetFileData');
const data = await streamRef.arrayBuffer();
console.log(data.byteLength);
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an example of receiving the stream as a return value.

@mkArtakMSFT mkArtakMSFT added the area-blazor Includes: Blazor, Razor Components label May 19, 2021
@@ -16,6 +20,7 @@ internal class RemoteJSRuntime : JSRuntime
private readonly CircuitOptions _options;
private readonly ILogger<RemoteJSRuntime> _logger;
private CircuitClientProxy _clientProxy;
private ConcurrentDictionary<long, (Stream Stream, bool LeaveOpen)> _pendingStreams = new();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider Pipes instead of Streams? You wouldn't need to manage buffers in SendDotNetStreamAsync

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Stream instance here comes from user code, which is a more familiar and flexible API to offer.

Were you suggesting we should use Pipe as the API in user code (as in, they must supply a Pipe to us), or were you suggesting this for only if it was an internal detail?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

were you suggesting this for only if it was an internal detail?

Right, I don't know the details of what users call, this just looked like a mostly internal thing.

@SteveSandersonMS
Copy link
Member Author

This proof-of-concept is no longer required as it was superseded by a real implementation.

@TanayParikh TanayParikh mentioned this pull request Jul 28, 2021
2 tasks
@dougbu dougbu deleted the stevesa/dotnet-to-js-stream-poc-new branch August 21, 2021 22:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-blazor Includes: Blazor, Razor Components
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants