diff --git a/src/Components/Web/src/Forms/InputFile.cs b/src/Components/Web/src/Forms/InputFile.cs index 64898847b4c8..70fba0ed1b25 100644 --- a/src/Components/Web/src/Forms/InputFile.cs +++ b/src/Components/Web/src/Forms/InputFile.cs @@ -25,7 +25,7 @@ public class InputFile : ComponentBase, IInputFileJsCallbacks, IDisposable private InputFileJsCallbacksRelay? _jsCallbacksRelay; [Inject] - private IJSRuntime JSRuntime { get; set; } = default!; + internal IJSRuntime JSRuntime { get; set; } = default!; // Internal for testing /// /// Gets or sets the event callback that will be invoked when the collection of selected files changes. diff --git a/src/Components/Web/src/Forms/InputFile/BrowserFileStream.cs b/src/Components/Web/src/Forms/InputFile/BrowserFileStream.cs index 8341c9362701..4747618b68c1 100644 --- a/src/Components/Web/src/Forms/InputFile/BrowserFileStream.cs +++ b/src/Components/Web/src/Forms/InputFile/BrowserFileStream.cs @@ -18,6 +18,7 @@ internal sealed class BrowserFileStream : Stream private readonly long _maxAllowedSize; private readonly CancellationTokenSource _openReadStreamCts; private readonly Task OpenReadStreamTask; + private IJSStreamReference? _jsStreamReference; private bool _isDisposed; private CancellationTokenSource? _copyFileDataCts; @@ -88,13 +89,15 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation private async Task OpenReadStreamAsync(CancellationToken cancellationToken) { - var dataReference = await _jsRuntime.InvokeAsync( + // This method only gets called once, from the constructor, so we're never overwriting an + // existing _jsStreamReference value + _jsStreamReference = await _jsRuntime.InvokeAsync( InputFileInterop.ReadFileData, cancellationToken, _inputFileElement, _file.Id); - return await dataReference.OpenReadStreamAsync( + return await _jsStreamReference.OpenReadStreamAsync( _maxAllowedSize, cancellationToken: cancellationToken); } @@ -116,6 +119,17 @@ protected override void Dispose(bool disposing) _openReadStreamCts.Cancel(); _copyFileDataCts?.Cancel(); + // If the browser connection is still live, notify the JS side that it's free to release the Blob + // and reclaim the memory. If the browser connection is already gone, there's no way for the + // notification to get through, but we don't want to fail the .NET-side disposal process for this. + try + { + _ = _jsStreamReference?.DisposeAsync().Preserve(); + } + catch + { + } + _isDisposed = true; base.Dispose(disposing); diff --git a/src/Components/Web/test/Forms/BrowserFileTest.cs b/src/Components/Web/test/Forms/BrowserFileTest.cs index d39f44a029de..14010454ac3f 100644 --- a/src/Components/Web/test/Forms/BrowserFileTest.cs +++ b/src/Components/Web/test/Forms/BrowserFileTest.cs @@ -3,6 +3,8 @@ using System; using System.IO; +using Microsoft.JSInterop; +using Moq; using Xunit; namespace Microsoft.AspNetCore.Components.Forms @@ -29,5 +31,48 @@ public void OpenReadStream_ThrowsIfFileSizeIsLargerThanAllowedSize() var ex = Assert.Throws(() => file.OpenReadStream(80)); Assert.Equal("Supplied file with size 100 bytes exceeds the maximum of 80 bytes.", ex.Message); } + + [Fact] + public void OpenReadStream_ReturnsStreamWhoseDisposalReleasesTheJSObject() + { + // Arrange: JS runtime that always returns a specific mock IJSStreamReference + var jsRuntime = new Mock(MockBehavior.Strict); + var jsStreamReference = new Mock(); + jsRuntime.Setup(x => x.InvokeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(ValueTask.FromResult(jsStreamReference.Object)); + + // Arrange: InputFile + var inputFile = new InputFile { JSRuntime = jsRuntime.Object }; + var file = new BrowserFile { Owner = inputFile, Size = 5 }; + var stream = file.OpenReadStream(); + + // Assert 1: IJSStreamReference isn't disposed yet + jsStreamReference.Verify(x => x.DisposeAsync(), Times.Never); + + // Act + _ = stream.DisposeAsync(); + + // Assert: IJSStreamReference is disposed now + jsStreamReference.Verify(x => x.DisposeAsync()); + } + + [Fact] + public async Task OpenReadStream_ReturnsStreamWhoseDisposalReleasesTheJSObject_ToleratesDisposalException() + { + // Arrange: JS runtime that always returns a specific mock IJSStreamReference whose disposal throws + var jsRuntime = new Mock(MockBehavior.Strict); + var jsStreamReference = new Mock(); + jsRuntime.Setup(x => x.InvokeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(ValueTask.FromResult(jsStreamReference.Object)); + jsStreamReference.Setup(x => x.DisposeAsync()).Throws(new InvalidTimeZoneException()); + + // Arrange: InputFile + var inputFile = new InputFile { JSRuntime = jsRuntime.Object }; + var file = new BrowserFile { Owner = inputFile, Size = 5 }; + var stream = file.OpenReadStream(); + + // Act/Assert. Not throwing is success here. + await stream.DisposeAsync(); + } } }