Skip to content

Fix unhandled ObjectDisposedException from the upload progress callback - #155

Merged
isourabh merged 1 commit into
mainfrom
azchohfi-issue-154-publish-crashes-with-an-unhandled-objec-34be88
Aug 20, 2026
Merged

Fix unhandled ObjectDisposedException from the upload progress callback#155
isourabh merged 1 commit into
mainfrom
azchohfi-issue-154-publish-crashes-with-an-unhandled-objec-34be88

Conversation

@azchohfi

Copy link
Copy Markdown
Collaborator

Fixes #154

Problem

AzureBlobManager.UploadFileAsync built its Azure upload progress handler as a lambda that read fileStream.Length on every tick:

using var fileStream = new FileStream(localFilePath, FileMode.Open, FileAccess.Read);
...
ProgressHandler = new Progress<long>(bytesTransferred =>
{
    progress.Report((double)bytesTransferred * 100 / fileStream.Length);
}),

Progress<T> dispatches its handlers asynchronously onto the thread pool (the CLI has no SynchronizationContext). When UploadAsync returns — or throws — the using disposes the stream while callbacks can still be queued. A queued callback then calls get_Length() on a closed stream and throws ObjectDisposedException on a thread-pool thread, outside the caller's try/catch in IStorePackagedAPIExtensions, so it is unhandled and kills the process:

Uploading Bundle to Azure blob: 0%
Error while uploading the application package.        <- caller's catch DID run
Unhandled exception. System.ObjectDisposedException: Cannot access a closed file.
   at System.Progress`1.InvokeHandlers(Object state)
   at System.Threading.ThreadPoolWorkQueue.Dispatch()

The crash is at 0%, after the caller's catch printed — so the ordering is: UploadAsync fails fast → using disposes → catch handles the upload failure → a still-queued callback takes the process down. Because publish has already run Deleting existing Submission by that point, this is disruptive in CI.

The race is pre-existing, but #133's move to .NET 10 with native AOT / single-file changed thread-pool scheduling and disposal timing enough to make it deterministic in v0.4.0.

Fix

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 escape onto the thread pool.

The guard is not redundant: progress is a Spectre.Console ProgressTask, and reporting into it after the progress display has been torn down can also throw from a thread-pool thread. The totalBytes > 0 check keeps NaN/Infinity out of the display for a 0-byte package.

Upload behaviour itself is unchanged — a failed upload still surfaces through the existing catch and returns -1.

Scope

AzureBlobManager is the only site with this pattern. FileDownloader.DownloadAsync and PWABuilderClient.GenerateZipAsync report progress synchronously on the awaiting thread and do not capture a disposed stream.

Verification

  • Reproduced against a verbatim copy of the original UploadFileAsync (same Azure.Storage.Blobs 12.29.1) pointed at a local blob endpoint that accepts part of the body then aborts: the reported ObjectDisposedException from Progress<T>.InvokeHandlers fires on every run, 13–18 callbacks escaping after the catch had already completed.
  • The same harness run against the patched code in this PR: 0 unhandled exceptions, empty stderr, progress still reported normally. Repeated alternating buggy/fixed runs are consistent.
  • New unit tests in MSStore.CLI.UnitTests/AzureBlobManagerUnitTests.cs lock in the invariant that the callback does not touch the file stream, plus percentage correctness, the 0-byte case, and the consumer-throws case.
  • Full suite run on both net10.0 and net10.0-windows10.0.17763.0 (the CI TFM); remaining failures are pre-existing and were confirmed against a clean baseline.

Side note for anyone reproducing this: on .NET 10 CoreCLR an unhandled thread-pool exception no longer terminates the process by default. The shipped CLI is NativeAOT (PublishAot=true), which fail-fasts — which is why the reporter sees exit code 1.

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Prevents late Azure upload progress callbacks from crashing the NativeAOT CLI after the file stream is disposed.

Changes:

  • Captures file length before upload and safely handles progress callbacks.
  • Adds regression tests for disposal, percentages, empty files, and reporting exceptions.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
MSStore.CLI/Services/AzureBlobManager.cs Makes upload progress callbacks independent of stream lifetime.
MSStore.CLI.UnitTests/AzureBlobManagerUnitTests.cs Tests progress callback safety and calculations.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@isourabh
isourabh merged commit 99b6b6b into main Aug 20, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

publish crashes with an unhandled ObjectDisposedException from the upload progress callback (regression in v0.4.0)

3 participants