-
Notifications
You must be signed in to change notification settings - Fork 10.5k
Release JS Blob after BrowserFileStream is disposed. Fixes #35540 #37004
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
Conversation
|
Looking for feedback from @TanayParikh on this solution. |
TanayParikh
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks great, just a question for my understanding.
| // notification to get through, but we don't want to fail the .NET-side disposal process for this. | ||
| try | ||
| { | ||
| _ = _jsStreamReference?.DisposeAsync().Preserve(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does Preserve do here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We use this pattern in a lot of places across ASP.NET Core and Blazor to avoid the analyzer warning https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2012 when doing fire-and-forget on a ValueTask. Unfortunately the explanation given in the CA2012 docs only says:
Ignoring such a ValueTask is likely an indication of a functional bug and may degrade performance
... but doesn't give deeper justification or concrete examples of why fire-and-forget may degrade performance. Knowing the internals of JS interop I don't see any obvious reason why it would be problematic for perf to fire-and-forget a disposeasync call, which we need to do and have a good justification for in this case (see comment in this code).
TBH I'm not really convinced that calling .Preserve() is the right thing to do instead of actually honestly suppressing the warning for real. @pranavkm I think I copied the pattern from you - do you know if .Preserve() actually avoids some real problem, or is this more a case of "Tell me you're suppressing CA2012 without telling me you're suppressing CA2012"?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh I think I know the reason why it could be a perf concern. ValueTask instances that wrap values from a reusable pool of task source instances can't return them to the pool until your await is done. So not awaiting could be leaking.
In this case, there's no IValueTaskSource or pooling so the concern is not applicable.
I suppose Preserve might release the original pooled object back to the pool and create a new one that external code can hold onto as long as it likes, so would genuinely be a valid way of not having the issue that CA2012 complains about (even though it's not applicable in our case anyway). But I don't really know for sure if my interpretation is correct, because the Preserve docs don't say anything about the implementation, and the sources are pretty nontrivial to reason about.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Anyway thanks for raising this question @TanayParikh - you forced me to learn more about this!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unfortunately the explanation given in the CA2012 docs only says: ... but doesn't give deeper justification or concrete examples of why fire-and-forget may degrade performance.
@stephentoub was an advocate for that analyzer (and enabled it in our repo). Perhaps some feedback for the description of the analyzer
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A ValueTask<T> can wrap a T, a Task<T>, or an IValueTaskSource<T>. Given an arbitrary ValueTask<T> (e.g. a function returns one to you), you should "consume" it once and only once, where "consume" here means awaiting it, calling .Result, calling .GetAwaiter().GetResult(), calling .AsTask(), etc.: basically you need to assume that the moment you've retrieved its result, it's no longer yours, because it may have been wrapping a pooled resource in the form of an IValueTaskSource<T>, and that implementation used its GetResult() implementation as a signal that you're now done with it and the object can be reused. https://devblogs.microsoft.com/dotnet/understanding-the-whys-whats-and-whens-of-valuetask/ provides a lot more details. The flip side of that, as is surmised earlier in the comment chain, is that if you get such an IValueTaskSource<T>-backed ValueTask<T> and never await it or otherwise call GetResult(), you're not sending that signal to the implementation, so for example if it's pooled, you just lost that object from the pool. Preserve() returns a new ValueTask<T> that is identical if it's backed by a T or Task<T>, but is the equivalent of doing new ValueTask<T>(oldVT.AsTask()) if it was backed by an IValueTaskSource<T>, and that AsTask() will properly register with the IValueTaskSource<T> to know when it's completed and call GetResult when it is. As a result, Preserve() can be used in a situation where a) you want fire-and-forget and might be dealing with a pooled resource, or b) you want to be able to consume the ValueTask<T> multiple times... both are advanced.
|
/backport to release/6.0 |
|
Started backporting to release/6.0: https://github.com/dotnet/aspnetcore/actions/runs/1303239855 |
…7248) Backport of #37004 to release/6.0 /cc @SteveSandersonMS ## Description/Customer Impact Our new implementation for `BrowserFileStream` in 6.0 did not release the JS-side `Blob` objects after each file transfer, hence leaking JS memory. This was reported by several customers and led to the browser hanging (or page being reloaded on mobile) when users upload many large files. ## Regression? - [X] Yes - [ ] No ## Risk - [ ] High - [ ] Medium - [X] Low * Change was verified manually and via a new unit test * The change is low complexity - it just involves disposing a JS object reference when the .NET-side stream is disposed Not fixing this would be a far higher risk, as we'd be shipping a known memory leak (on the JS side, but still enough to make apps fail) ## Verification - [X] Manual (required) - [X] Automated ## Packaging changes reviewed? - [ ] Yes - [ ] No - [X] N/A Fixes #35540
Fixes #35540, which was reported by several customers and ultimately could lead to the browser running out of memory if uploading a lot of large files.
The underlying issue here is that:
IJSStreamReferencedirectly through manual interop code, then as well as disposing anyStreaminstances you get by callingjsStreamReference.OpenReadStreamAsync, it's also your responsibility to dispose theIJSStreamReferenceitself. That's the point at which the JS-sideBlobgets released, because until then, you could keep on callingOpenReadStreamAsyncmore times and continue to read the blob data.InputFileand you callOpenReadStreamon one of theIBrowserFileinstances it gives you, then you get aStream, but you never get to see the underlyingIJSStreamReferencethat we're using internally. In fact, our implementationBrowserFileStreamdoesn't even keep a reference to theIJSStreamReference- it just transiently calls theOpenReadStreamAsyncmethod and discards theIJSStreamReference. As such, even when you manually dispose theBrowserFileStreamit doesn't dispose the underlyingIJSStreamReference, and hence the browser never releases theBlob.The fix then is pretty simple.
BrowserFileStreamshould keep track of theIJSStreamReferencethat it's using for transport, and it should dispose it when itself is disposed.One further caveat is that with Blazor Server, there's no guarantee the connection to the browser still exists at this point. And we don't want the failure to dispose the
IJSStreamReferenceto cause the .NET circuit disposal process itself to fail. So, when we dispose the JS stream reference we just do it on a "best effort, ignore failures" basis. Strictly speaking you could still leak memory if the disposal happens while the circuit is disconnected and then the circuit reconnects later, and this happens over and over. However that's extremely unlikely, and really is a broader underlying issue withIJSObjectReference- that if you try to dispose them while the circuit is disconnected, the disposal doesn't happen and nothing will automatically retry it later. If we're speaking super strictly, then we could say we're leaving the application developer to track and replay all JS interop calls that happen while disconnected if they really want to (since the semantics would always be scenario-specific), but it's incredibly rare for anyone to want to do that.Testing
It's not really sensible to try to E2E test this, because external systems have no visibility into the internal state of JS object reference caching. So what I have done is:
cachedJSObjectsByIdentries when theBrowserFileStreamis disposed