Skip to content

Fix covariant returns scenarios in task-returning methods - #132425

Open
VSadov with Copilot wants to merge 31 commits into
mainfrom
copilot/handle-covariant-returns
Open

VSadov with Copilot wants to merge 31 commits into
mainfrom
copilot/handle-covariant-returns

Conversation

Copilot AI commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Fixes covariant returns in runtime async scenarios where an override returns a type derived from Task/Task<T> rather than Task/Task<T> itself.

Problem

Task and Task<T> are not sealed, so a covariant override may return, say, MyTask : Task. Such a method is not task-returning as far as the runtime is concerned, so it got no Async variant and could not override the Async variant of the base method. As a result, when the call was made in a way that could become a runtime async call (e.g. await b.M1()), the base implementation ran instead of the override. The same code compiled without the runtime-async feature dispatched correctly.

Fix

  • methodtablebuilder.cpp — when enumerating class methods, detect a virtual MethodImpl that requires covariant return type checking and whose decl is task-returning. Such a method is now treated as task-returning and gets exactly one Async variant, whose return type is the element type of the overridden method (void for Task, T for Task<T>). Type variables of the declaring type in T are substituted from the TypeSpec instantiation of the decl, so generic types/methods, closed and composed base instantiations are handled.

  • New AsyncMethodFlags::CovariantForwardingThunk — this variant is always a thunk.
    The thunk's purpose is to switch to a different virtual slot and continue dispatching. The MyTask- returning method may be further overriden, thus the thunk needs to contain CALLVIRT to the non-async variant.

The forwarding thunk was chosen over async version so that only the covarainly overriding methods may need another variant and we can continue mostly ignoring non-Task returning methods for async purposes (only covariant overrides have a special case and check if base returns a Task).

  • asyncthunks.cpp — EmitCovariantForwardingThunk emits the Async variant as CALLVIRT of the ordinary variant followed by AsyncHelpers.TransparentAwait/TransparentAwait<T> on the returned task.

  • FindDeclMethodOnClassInHierarchy — use GetParallelMethodDesc instead of GetAsyncVariant(); the latter may create an InstantiatedMethodDesc and load types, which is not allowed while building a MethodTable (fires a contract violation on checked builds). Only the slot is needed.

  • readytoruninfo.cpp — like return-dropping thunks, covariant forwarding thunks are VM-synthesized and share the token/signature shape of a regular async variant, so R2R lookup is skipped for them and the IL is generated transiently in the prestub.

  • cDAC / data contract — the new flag is added to AsyncMethodFlags in the contract docs and readers, and the thunk is treated as diagnostics-hidden.

Tests

src/tests/async/covariant-return/covariant-returns.cs adds coverage for:

  • overrides returning MyTask/MyTask<int>, awaited directly and observed as Task/Task<T> objects,
  • covariant override of a covariant override, and calls on the derived static type,
  • overridden methods annotated [RuntimeAsyncMethodGeneration(false)],
  • generic methods, generic declaring types, Task<List<T>>/Task<T[]> element types, struct element types, closed (GBase<int>) and composed (GBase<List<U>>) base instantiations.

Not in scope

Copilot AI lite review requested due to automatic review settings August 17, 2026 18:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot wasn't able to review any files in this pull request.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
16 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

Copilot AI review requested due to automatic review settings August 17, 2026 18:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 1 out of 1 changed files in this pull request and generated no new comments.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke
See info in area-owners.md if you want to be subscribed.

Copilot AI review requested due to automatic review settings August 17, 2026 19:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 1 out of 1 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/tests/async/covariant-return/covariant-returns.cs:404

  • PR metadata says "Fixes #124238", but the added coverage is currently marked [ActiveIssue] (and this PR doesn't include a runtime/product fix), so it doesn't actually fix the issue yet. Consider updating the PR title/description to reflect that this is a repro/coverage-only change until the runtime fix is included and the [ActiveIssue] attributes can be removed.
        [Fact]
        [ActiveIssue("https://github.com/dotnet/runtime/issues/124238")]
        public static void TestCustomTaskOverride()
        {

Copilot AI requested a review from VSadov August 17, 2026 19:50
@VSadov

VSadov commented Aug 17, 2026 •

Copy link
Copy Markdown
Member

@copilot lets try fixing the runtime behavior

  • we need to detect the scenario and in this special case the derived methods should be classified as task-returning (not ordinary).
    Basically, the desired invariant is: if the base method is task-returning, then the derived is task-returning.
  • the derived method will need to get an async thunk variant, just like other task-returning methods.
    The thunk would naturally forward to the actual non-async method. Since the defined method does not formally return a task, it cannot be async, so it is always a thunk.
  • the return type of the async thunk should be the "element" type of the base method: void if it is just Task, T if it is Task<T>.
  • the T can be a concrete type like "int" in the current test case. Lets make that work first.
    (do not get concerned with T being a generic type, just yet, will follow up with that later)

Copilot AI review requested due to automatic review settings August 17, 2026 21:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/coreclr/vm/methodtablebuilder.cpp:2666

  • PR description says the runtime fix is out of scope for this PR, but this change adds a runtime behavior change in MethodTableBuilder to synthesize async variants for covariant Task overrides. Please update the PR description/checklist (or split into separate PRs) so scope and CI expectations match the actual contents.
// Task and Task<T> are not sealed, thus a covariant override may return a type that derives from
// Task/Task<T>, while not being Task/Task<T> itself. Such a method is not Task-returning on its own,
// but the method that it overrides may well be. Since the overridden method has an Async variant,
// the override must have one as well, or it would not be able to override it.
//

src/coreclr/vm/methodtablebuilder.cpp:2704

  • TryGetCovariantOverrideAsyncVariantReturnType bails out for MethodImpl declarations that are MemberRefs with a TypeSpec parent (instantiated generic type). That means covariant overrides of Task/Task-returning methods on closed generic base types won’t get an async variant and will likely keep the incorrect dispatch behavior this change is trying to address.
        // The signature of a member of an instantiated generic type may refer to the generic
        // parameters of that type. Such references do not have the same meaning in the scope of
        // the overriding method, so we cannot reuse the signature. That case is not supported yet.
        mdToken tkParent;
        if (FAILED(pMDInternalImport->GetParentToken(tkDecl, &tkParent)) ||
            (TypeFromToken(tkParent) == mdtTypeSpec))
        {
            return false;
        }

@VSadov

VSadov commented Oct 20, 2026

Copy link
Copy Markdown
Member

@copilot is the failure on "windows-x64 Checked NativeAOT" related to this change?

Copilot AI commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

@copilot is the failure on "windows-x64 Checked NativeAOT" related to this change?

Yes. In build 1642635 (windows-x64 Checked NativeAOT), compiling src/tests/async/async.csproj aborts at CompilerTypeSystemContext.Async.cs:40: resolvedMethod.Signature.ReturnsTaskOrValueTask(). The IL regression added in 323720f9 to async-versions.il has ModifiedBase.Get() returning Task<int> and ModifiedDerived.Get() returning ModifiedTask : Task<int>. NativeAOT's async-variant resolver does not handle that covariant override. Unlike the separate custom-task-covariant-return project, async-versions is included in NativeAOT's merged async test build. This is a PR-related test-build failure, not an unrelated CI issue; the IL case needs to be excluded from NativeAOT or NativeAOT support implemented.

Note

This reply was generated with GitHub Copilot.

@VSadov

VSadov commented Oct 21, 2026

Copy link
Copy Markdown
Member

@copilot exclude the aot failing scenario on aot

Co-authored-by: VSadov <8218165+VSadov@users.noreply.github.com>
Co-authored-by: VSadov <8218165+VSadov@users.noreply.github.com>
@VSadov

VSadov commented Oct 26, 2026

Copy link
Copy Markdown
Member

@jkotas - anything else that we need to address in this PR?

This comment was marked as resolved.

Copilot AI and others added 2 commits October 27, 2026 05:02
Co-authored-by: VSadov <8218165+VSadov@users.noreply.github.com>
Co-authored-by: VSadov <8218165+VSadov@users.noreply.github.com>
@VSadov

VSadov commented Oct 27, 2026

Copy link
Copy Markdown
Member

@copilot please fix build failures

Copilot AI commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

@copilot please fix build failures

The current build failures are unrelated to this PR. All four failing pipelines (1650332–1650335) report CS0115 in NewObjectFixupSignature.ComputeNonRelocationBasedDependencies: main's #132955 changed the dependency API to use sinks, while #135802 added an override using the old API. I verified the incompatible code is already present in the CI merge's main parent (6d19d4682bd), and this PR changes none of those compiler files.

The fix is already open in #136497. No code changes or CI reruns made here; this PR needs the upstream fix before the merged CI tree can build.

This comment was marked as resolved.

Co-authored-by: VSadov <8218165+VSadov@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Loader-time generic signature substitution and synthesized virtual async methods require specialist review and checked-runtime/ReadyToRun validation beyond what static inspection can establish.

No open issues 🎉

2 resolved since last review

Review effort: Balanced

@VSadov

VSadov commented Oct 29, 2026

Copy link
Copy Markdown
Member

@jkotas - seems no more issues that copilot could find.
(not sure why it was finding them one-by-one)

@VSadov

VSadov commented Oct 31, 2026

Copy link
Copy Markdown
Member

@copilot check the test failures. at least one seems related to this PR

Co-authored-by: VSadov <8218165+VSadov@users.noreply.github.com>

Copilot AI commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

@copilot check the test failures. at least one seems related to this PR

Found PR-related failures and addressed them in 78f088d7a:

  • TestSynchronizedCustomTaskOverride(false/true) calls Thread.Start() on single-threaded browser WASM. The theory now requires IsMultithreadingSupported; other covariant tests remain enabled.
  • Both new regression assemblies locally abort Checked Crossgen2 at resolvedMethod.Signature.ReturnsTaskOrValueTask(). They now run separately with CrossGenTest=false, preserving VM execution without exposing unsupported inputs to the merged runner’s compilation. AOT support remains tracked by NativeAOT Follow up issue for the covariant returns scenarios in task-returning methods #136339.

Validated Linux x64 Checked: baseline and async builds passed; all 149 runner entries passed in both ReadyToRun and non-ReadyToRun modes, including both isolated regressions. Review and secret scan passed. CodeQL was invoked but skipped C# analysis because the database was too large.

The TLS failure is unrelated and already matched to #136560. The browser-WASM R2R crash is consistent with the reproduced compiler failure, but I cannot confirm its exact cause: the Helix console/dump storage hostname fails DNS resolution here. Browser-WASM execution was not validated locally.

@pcshrosbree pcshrosbree left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two suggestions: drop TryGetDeclaringTypeInstantiation, whose non-empty result I only saw for types that then fail to load, and update three comments that still give the dropped reason for the thunk. I built 78f088d and its merge base 152ad96 locally (Release, linux-x64) and ran the cases below on both.

// Returns false if the declaring type could not be reached without loading types
// (i.e. it is in another module) or if the case is not supported.
//
static bool TryGetDeclaringTypeInstantiation(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this helper could go. Its result is non-empty only when the declaration is a MethodDef on a generic type, and in my runs on this PR's build no type with such a declaration loaded.

A MethodImpl whose declaration is a MethodDef on a generic base failed type load when the loader looked up the declaration, whether or not the return was covariant. I assembled this with ilasm 10.0.0 (the class's constructor is left out here):

.class public auto ansi beforefieldinit E`1<T> extends class GBase`1<!T>
{
    .method public hidebysig newslot virtual instance class MyTask`1<!T> M(!T t) cil managed
    {
        .override method instance class [System.Runtime]System.Threading.Tasks.Task`1<!0> GBase`1::M(!0)
        ldarg.1
        newobj instance void class MyTask`1<!T>::.ctor(!0)
        ret
    }
}

Creating an E<string> fails with Method override 'M' on type 'E`1' ... cannot find a method to replace. The same happens when the override returns Task<T>, and for a covariant override two generic extends hops below GBase<T>. The same class with .override ... class GBase`1<!T>::M(!0), a MemberRef on a TypeSpec, loads and the call reaches the override. So does a MethodDef declaration on a non-generic base. The merge base gives the same five results.

With an fprintf added locally to the helper, it returned a non-empty instantiation only for the one-hop and two-hop cases, and both types then failed to load. Across src/tests/async (149 passed, including this PR's two new tests), it ran 13 times, never walked a generic base, and always returned an empty instantiation.

In the three C# shapes I compiled with the 10.0.402 SDK, which mirror this PR's tests, the declaration for a generic base is a MemberRef on a TypeSpec. For example, MultiHopDerived<U> gets GBase<List<!0>[]>::M1. So the multi-hop test that was added for this loop goes through the MemberRef branch of TryGetCovariantOverrideAsyncVariantReturnType instead.

So the MethodDef branch only needs an empty instantiation, since a non-generic declaring type has no !n to substitute.

Comment thread src/coreclr/vm/method.hpp
Comment on lines +78 to +80
// A special thunk for an override that covariantly returns a type derived from Task/Task<T>.
// Such a method does not formally return Task/Task<T>, so its IL cannot be compiled as an
// async version. The thunk calls the ordinary variant and awaits the returned Task instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could this comment give the reason from the EmitCovariantForwardingThunk comment instead: a thunk means only covariant overrides need the extra variant? The same applies to methodtablebuilder.cpp at lines 3784-3785 and 3913-3915. All three still give the reason that the PR description and that comment dropped after the earlier discussion here.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[RuntimeAsync] Handle covariant returns scenarios in combination with task-returning methods.

8 participants