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.
Under parallel execution, MSTest's
TestExecutionManagerawaits its worker tasks withawait Task.WhenAll(tasks)starting from #4586, and never referencestasksagain. Since .NET 8 the non-genericTask.WhenAllpromise no longer roots its source tasks (dotnet/runtime#81065), so the only thing keeping the source tasks alive is thattaskslocal. 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
WhenAllstays 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.Keepaliveis 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.