Skip to content

Fix async APIs for WebAssembly compatibility - #3610

Open
xuzhg wants to merge 3 commits into
dev-9.xfrom
xuzhg/fix-3605-async-wasm-dev-9.x
Open

xuzhg wants to merge 3 commits into
dev-9.xfrom
xuzhg/fix-3605-async-wasm-dev-9.x

Conversation

@xuzhg

@xuzhg xuzhg commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

  • replace remaining APM-based async wrappers with native task-based implementations
  • preserve 9.x action-result and save-event behavior
  • add WASM regression coverage that rejects synchronous and Begin/End response APIs
  • update bulk-update and deep-insert test messages for native async responses

Tests

  • 648 Microsoft.OData.Client unit tests passed on .NET 10

Fixes #3605

@xuzhg
xuzhg requested review from WanjohiSammy and a lite review from Copilot August 31, 2026 17:53
@xuzhg

xuzhg commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

/AzurePipelines run

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

This PR updates Microsoft.OData.Client async entry points to avoid APM (Begin/End + blocking waits) and instead use native Task-based async, improving Blazor WebAssembly compatibility while expanding regression coverage to ensure sync/APM response paths aren’t used.

Changes:

  • Replaced remaining FromAsync(Begin…, End…) wrappers with native async implementations for save/batch/paging/stream/bulk/deep-insert APIs.
  • Updated DataServiceActionQuerySingle<T>.GetValueAsync to use ExecuteAsync and refreshed unit tests accordingly.
  • Expanded WASM-focused unit tests and updated functional test request-message mocks to support GetResponseAsync.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/Microsoft.OData.Client/DataServiceContext.cs Converts multiple public async APIs to native async implementations (save/batch/load property/read stream/bulk/deep insert/paging).
src/Microsoft.OData.Client/DataServiceActionQuerySingleOfT.cs Switches action single-result async execution to ExecuteAsync and preserves nullable/non-nullable semantics.
test/UnitTests/Microsoft.OData.Client.Tests/Serialization/AsyncWasmCompatibilityTests.cs Adds broader WASM regression coverage and enforces “no sync/APM response APIs” in the test request message.
test/UnitTests/Microsoft.OData.Client.Tests/DataServiceActionQuerySingleTests.cs Updates tests to validate the new native async execution path and cancellation token flow.
test/FunctionalTests/Tests/DataServices/UnitTests/Client.TDD.Tests/Tests/DeepInsertE2ETests.cs Updates mock request message to support GetResponseAsync.
test/FunctionalTests/Tests/DataServices/UnitTests/Client.TDD.Tests/Tests/BulkUpdateE2ETests.cs Updates mock request message to support GetResponseAsync.
Suppressed comments (2)

src/Microsoft.OData.Client/DataServiceContext.cs:2282

  • BulkUpdateAsync now passes a hard-coded method name ("BulkUpdateAsync") into BulkUpdateSaveResult. The BulkUpdate/BeginBulkUpdate paths use Util.BulkUpdateMethodName; using the same constant keeps method-name-based diagnostics consistent and avoids duplicated strings.

            BulkUpdateSaveResult result = new BulkUpdateSaveResult(this, "BulkUpdateAsync", SaveChangesOptions.BulkUpdate, null, null);
            await result.BulkUpdateRequestAsync(cancellationToken, objects).ConfigureAwait(false);

src/Microsoft.OData.Client/DataServiceContext.cs:2355

  • DeepInsertAsync has inconsistent modifier ordering ("public async virtual" vs the rest of this type's "public virtual async") and also hard-codes the method name ("DeepInsertAsync") when constructing DeepInsertSaveResult. Aligning both with existing conventions (and Util.DeepInsertMethodName) keeps the codebase consistent and preserves method-name-based diagnostics.
        public async virtual Task<DataServiceResponse> DeepInsertAsync<T>(T resource, CancellationToken cancellationToken)
        {
            if (resource == null)
            {
                throw Error.ArgumentNull(nameof(resource));

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

Comment thread src/Microsoft.OData.Client/DataServiceContext.cs
Comment thread src/Microsoft.OData.Client/DataServiceContext.cs Outdated
Comment thread src/Microsoft.OData.Client/DataServiceContext.cs
@xuzhg

xuzhg commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

/AzurePipelines run

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

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Suppressed comments (5)

test/EndToEndTests/Tests/Client/Microsoft.OData.Client.E2E.Tests/CancellationTokenTests/Tests/CancellationTokenTests.cs:91

  • Avoid asserting on the exact exception type/message for cancellation. TaskCanceledException vs OperationCanceledException and the exception message are runtime/localization-dependent; asserting on them makes this E2E test brittle.
        var exception2 = await Assert.ThrowsAsync<TaskCanceledException>(response2);
        Assert.Equal("A task was canceled.", exception2.Message);

test/EndToEndTests/Tests/Client/Microsoft.OData.Client.E2E.Tests/CancellationTokenTests/Tests/CancellationTokenTests.cs:212

  • Avoid asserting on the exact exception type/message for cancellation. TaskCanceledException vs OperationCanceledException and the exception message are runtime/localization-dependent; asserting on them makes this E2E test brittle.
        var exception = await Assert.ThrowsAsync<TaskCanceledException>(response);
        Assert.Equal("A task was canceled.", exception.Message);

test/EndToEndTests/Tests/Client/Microsoft.OData.Client.E2E.Tests/CancellationTokenTests/Tests/CancellationTokenTests.cs:295

  • Avoid asserting on the exact exception type/message for cancellation. TaskCanceledException vs OperationCanceledException and the exception message are runtime/localization-dependent; asserting on them makes this E2E test brittle.
        var exception = await Assert.ThrowsAsync<TaskCanceledException>(response);
        Assert.Equal("A task was canceled.", exception.Message);

test/EndToEndTests/Tests/Client/Microsoft.OData.Client.E2E.Tests/CancellationTokenTests/Tests/CancellationTokenTests.cs:226

  • Avoid asserting on the exact exception type/message for cancellation. TaskCanceledException vs OperationCanceledException and the exception message are runtime/localization-dependent; asserting on them makes this E2E test brittle.
        var exception2 = await Assert.ThrowsAsync<TaskCanceledException>(response2);
        Assert.Equal("A task was canceled.", exception2.Message);

test/EndToEndTests/Tests/Client/Microsoft.OData.Client.E2E.Tests/CancellationTokenTests/Tests/CancellationTokenTests.cs:231

  • Avoid asserting on the exact exception type/message for cancellation. TaskCanceledException vs OperationCanceledException and the exception message are runtime/localization-dependent; asserting on them makes this E2E test brittle.
        var exception3 = await Assert.ThrowsAsync<TaskCanceledException>(response3);
        Assert.Equal("A task was canceled.", exception3.Message);

Comment on lines +79 to +80
var exception = await Assert.ThrowsAsync<TaskCanceledException>(response);
Assert.Equal("A task was canceled.", exception.Message);
@uo-uhbc

uo-uhbc commented Sep 22, 2026

Copy link
Copy Markdown

I built this branch locally (head 6a20a6d4) to check whether it resolves #3605 for a real Blazor WebAssembly app, and I think there is a gap worth addressing before merge: the save paths still read the response body synchronously, so on WASM they fail — just with a different exception than the one reported in #3605.

Why the current tests don't show it

AsyncTestRequestMessage.CreateMockResponse() returns () => new MemoryStream(_response). A MemoryStream serves synchronous Read and BeginRead/EndRead happily, so the new tests prove the APM bridge is gone from the request message, but never exercise a body that behaves like BrowserHttpReadStream.

Reproduction

I reused the existing AsyncWasmCompatibilityTests setup and changed exactly one thing: the response stream throws NotSupportedException("net_http_synchronous_reads_not_supported") from Read, while serving ReadAsync normally — i.e. what the browser hands you under WASM.

Scenario Result Where
LoadPropertyAsync (control) passes —
GetReadStreamAsync (control) passes —
SaveChangesAsync() (non-batch) fails WebUtil.CopyStream → SaveResult.HandleOperationResponseData
SaveChangesAsync(BatchWithSingleChangeset) fails ODataBatchReader.ReadSynchronously → BatchSaveResult.HandleBatchResponse
BulkUpdateAsync fails JsonReader.Read → BulkUpdateSaveResult.HandleBulkUpdateResponse
DeepInsertAsync fails JsonReader.Read (deep-insert response parsing)

All four fail with System.NotSupportedException: net_http_synchronous_reads_not_supported. The two controls passing through the same stream is the point: the replacement stream isn't simply breaking everything, it isolates the save family. On the same build, the branch's own 25 AsyncWasmCompatibilityTests all pass.

Non-batch:

NotSupportedException: net_http_synchronous_reads_not_supported
   at Microsoft.OData.Client.WebUtil.CopyStream(Stream, Stream, Byte[])              WebUtil.cs:81
   at Microsoft.OData.Client.SaveResult.HandleOperationResponseData(IODataResponseMessage)  SaveResult.cs:884
   at Microsoft.OData.Client.SaveResult.CreateNextChangeAsync(CancellationToken)     SaveResult.cs:302
   at Microsoft.OData.Client.DataServiceContext.SaveChangesAsync(SaveChangesOptions, CancellationToken)  DataServiceContext.cs:2170

Batch:

NotSupportedException: net_http_synchronous_reads_not_supported
   at Microsoft.OData.ODataBatchReaderStreamBuffer.RefillFrom(Stream, Int32)
   at Microsoft.OData.MultipartMixed.ODataMultipartMixedBatchReaderStream.DetectEncoding(Stream)
   at Microsoft.OData.ODataBatchReader.ReadSynchronously()
   at Microsoft.OData.Client.BatchSaveResult.HandleBatchResponse()                   BatchSaveResult.cs:562
   at Microsoft.OData.Client.BaseSaveResult.EndRequest()                             BaseSaveResult.cs:304
   at Microsoft.OData.Client.DataServiceContext.SaveChangesAsync(SaveChangesOptions, CancellationToken)  DataServiceContext.cs:2173

Root cause

SendInternalAsync sends with HttpCompletionOption.ResponseHeadersRead, and ConvertHttpWebResponseAsync hands back the live content stream:

Stream contentStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
return new HttpWebResponseMessage(…, getResponseStream: () => contentStream, getResponseStreamAsync: null);

The query path copes because QueryResult.ProcessResponseStreamAsync copies that body with WebUtil.CopyStreamAsync before anything parses it. The save paths don't: BatchRequestAsync, DeepInsertRequestAsync and BulkUpdateRequestAsync assign responseStream = batchResponseMessage.GetStream() directly, and SaveResult copies with the synchronous WebUtil.CopyStream. Parsing then uses the synchronous ODL readers.

Possible fixes

  1. Minimal and consistent with what's already here: buffer the body asynchronously before parsing, the way ProcessResponseStreamAsync does — WebUtil.CopyStreamAsync already sits next to CopyStream.
  2. Deeper: move the save pipeline to ODL's async reader APIs (CreateODataBatchReaderAsync, async JSON reading), which removes the need to buffer at all.

Either way it would help to have a response stream in the tests that rejects synchronous reads. #3538 already introduces exactly such a helper (AsyncReadOnlyStream); reusing that shape here would make these four cases visible. I'm happy to contribute the tests I used if that's useful — just say where you'd want it.

Two smaller notes

  • The added argument checks (Util.CheckArgumentNotEmpty(queries, …), the continuation null check, resource == null, objects == null || objects.Length == 0) now sit inside async methods, so they surface as a faulted Task rather than throwing synchronously as Task.Factory.FromAsync did (it invokes the Begin delegate synchronously). A non-async wrapper delegating to a private async core would preserve the old behaviour. The new ExecuteBatchAsync_WithEmptyQueries_Throws uses Assert.ThrowsAsync, so it passes either way.
  • In ExecuteBatchAsync the queries check now runs before the Util.IsBatch(options) check, so a call with non-batch options and empty queries throws ArgumentException where it previously threw InvalidOperationException.

This branch has not been deployed

No deployments
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.

3 participants