Skip to content

TestExecutionManager Task.WhenAll loses its source tasks to GC #9183

Description

@NinoFloris

Under parallel execution, MSTest's TestExecutionManager awaits its worker tasks with await Task.WhenAll(tasks) starting from #4586, and never references tasks again. Since .NET 8 the non-generic Task.WhenAll promise no longer roots its source tasks (dotnet/runtime#81065), so the only thing keeping the source tasks alive is that tasks local. Roslyn however drops it from the state machine because it's not read after the await.

If a GC runs while a test task is suspended in an await chain that has no other external root, the incomplete task is collected, its completion can never propagate, and the WhenAll stays pending forever. The runner hangs with no exception and no test failure.

It's timing-dependent (needs a GC in a specific window) so it's rare, which is what makes the hang very nasty. The symptom is far from the cause, and the GC also deletes the evidence you'd use to diagnose it.

Repro (should reproduce on anything newer than .NET 8.x):
https://gist.github.com/NinoFloris/82233469bf111e6c8d6e6d0e148e06f4

The fix is to make sure TestExecutionManager keeps the tasks alive past the await, GC.Keepalive is the easiest way.

IMO this exact issue should be something that makes the runtime team reconsider the 8.0 behavior change. The Task<T> overload sits on the exact same method name unaffected by this, precisely because it roots the tasks to be able to read their results.

It is a massive footgun in the way the api differs between overloads, given how the language and the GC interact here.

Metadata

Metadata

Assignees

Labels

external/otherCaused by an external issue that needs to be solved first.needs/triageNeeds triage by a maintainer.

Type

No fields configured for Bug.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions