Skip to content

Commit d457344

Browse files
azchohfiCopilot
andcommitted
Fix unhandled ObjectDisposedException from upload progress callback
The Azure blob upload progress handler read fileStream.Length on every tick. Progress<T> dispatches its handlers on the thread pool, so a queued callback could run after UploadFileAsync had returned or thrown and the `using` had already disposed the stream. get_Length() then threw ObjectDisposedException on a thread-pool thread, outside the caller's try/catch in IStorePackagedAPIExtensions, taking the whole process down with exit code 1. Capture the length once before the upload so the callback never touches the stream, and guard the handler body so a late callback can never terminate the process. The guard also covers the progress consumer itself: the CLI passes a Spectre.Console ProgressTask, which can throw once the progress display has been torn down. Fixes #154 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4f872603-9e85-44d2-a627-788a2806492a
1 parent 50d9212 commit d457344

2 files changed

Lines changed: 120 additions & 4 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using MSStore.CLI.Services;
5+
6+
namespace MSStore.CLI.UnitTests
7+
{
8+
[TestClass]
9+
public class AzureBlobManagerUnitTests
10+
{
11+
private sealed class RecordingProgress : IProgress<double>
12+
{
13+
public List<double> Reported { get; } = [];
14+
15+
public void Report(double value) => Reported.Add(value);
16+
}
17+
18+
private sealed class ThrowingProgress : IProgress<double>
19+
{
20+
public void Report(double value) => throw new InvalidOperationException("Progress display already torn down.");
21+
}
22+
23+
/// <summary>
24+
/// Regression test for https://github.com/microsoft/msstore-cli/issues/154.
25+
///
26+
/// The upload progress callback used to read fileStream.Length. Progress&lt;T&gt; dispatches its
27+
/// handlers on the thread pool, so a queued callback could run after UploadFileAsync had returned or
28+
/// thrown and the `using` had disposed the stream. That threw ObjectDisposedException on a
29+
/// thread-pool thread, outside the caller's try/catch, and took the whole process down.
30+
/// </summary>
31+
[TestMethod]
32+
public void CreateProgressCallbackDoesNotTouchTheFileStreamAfterItIsDisposed()
33+
{
34+
var path = Path.GetTempFileName();
35+
36+
try
37+
{
38+
File.WriteAllBytes(path, new byte[1000]);
39+
40+
var progress = new RecordingProgress();
41+
Action<long> callback;
42+
43+
using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read))
44+
{
45+
callback = AzureBlobManager.CreateProgressCallback(fileStream.Length, progress);
46+
}
47+
48+
// The stream is now disposed, exactly as it is when a late callback is dispatched.
49+
callback(250);
50+
51+
Assert.AreSequenceEqual([25d], progress.Reported);
52+
}
53+
finally
54+
{
55+
File.Delete(path);
56+
}
57+
}
58+
59+
[TestMethod]
60+
public void CreateProgressCallbackReportsPercentage()
61+
{
62+
var progress = new RecordingProgress();
63+
var callback = AzureBlobManager.CreateProgressCallback(200, progress);
64+
65+
callback(0);
66+
callback(50);
67+
callback(200);
68+
69+
Assert.AreSequenceEqual([0d, 25d, 100d], progress.Reported);
70+
}
71+
72+
[TestMethod]
73+
public void CreateProgressCallbackDoesNotReportForAnEmptyFile()
74+
{
75+
var progress = new RecordingProgress();
76+
var callback = AzureBlobManager.CreateProgressCallback(0, progress);
77+
78+
// Must not divide by zero and push NaN/Infinity into the progress display.
79+
callback(0);
80+
81+
Assert.IsEmpty(progress.Reported);
82+
}
83+
84+
[TestMethod]
85+
public void CreateProgressCallbackSwallowsExceptionsFromTheProgressConsumer()
86+
{
87+
// The CLI passes a Spectre.Console ProgressTask, which can throw once the progress display has
88+
// been torn down. A late callback runs on a thread-pool thread, so anything escaping here would
89+
// terminate the process.
90+
var callback = AzureBlobManager.CreateProgressCallback(100, new ThrowingProgress());
91+
92+
callback(50);
93+
}
94+
}
95+
}

MSStore.CLI/Services/AzureBlobManager.cs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ public async Task<string> UploadFileAsync(string blobUri, string localFilePath,
1919
{
2020
using var fileStream = new FileStream(localFilePath, FileMode.Open, FileAccess.Read);
2121

22+
// Capture the length up front. Progress<T> dispatches its handlers on the thread pool, so a
23+
// callback can still run after this method has returned (or thrown) and fileStream has been
24+
// disposed. The callback must therefore never touch fileStream.
25+
var totalBytes = fileStream.Length;
26+
2227
var blobClientOptions = new BlobClientOptions();
2328
blobClientOptions.Retry.NetworkTimeout = TimeSpan.FromSeconds(uploadTimeout);
2429
blobClientOptions.AddPolicy(new AddCorrelationIdHeaderPolicy(), HttpPipelinePosition.PerCall);
@@ -29,10 +34,7 @@ public async Task<string> UploadFileAsync(string blobUri, string localFilePath,
2934
{
3035
ContentType = "application/zip"
3136
},
32-
ProgressHandler = new Progress<long>(bytesTransferred =>
33-
{
34-
progress.Report((double)bytesTransferred * 100 / fileStream.Length);
35-
}),
37+
ProgressHandler = new Progress<long>(CreateProgressCallback(totalBytes, progress)),
3638
};
3739

3840
var response = await blobClient.UploadAsync(fileStream, blobUploadOptions, ct);
@@ -46,6 +48,25 @@ public async Task<string> UploadFileAsync(string blobUri, string localFilePath,
4648
}
4749
}
4850

51+
internal static Action<long> CreateProgressCallback(long totalBytes, IProgress<double> progress)
52+
{
53+
return bytesTransferred =>
54+
{
55+
try
56+
{
57+
if (totalBytes > 0)
58+
{
59+
progress.Report((double)bytesTransferred * 100 / totalBytes);
60+
}
61+
}
62+
catch (Exception)
63+
{
64+
// Progress reporting is best-effort. This runs on a thread-pool thread, outside the
65+
// caller's try/catch, so anything that escapes here would terminate the process.
66+
}
67+
};
68+
}
69+
4970
public class AddCorrelationIdHeaderPolicy() : HttpPipelineSynchronousPolicy
5071
{
5172
public override void OnSendingRequest(HttpMessage message)

0 commit comments

Comments
 (0)