Skip to content

Commit 039e615

Browse files
committed
Coverage for "await foreach" loops (issue #1104)
This commit attempts to fix code coverage for the "await foreach" loop introduced in C# 8. The presence of an "await foreach" loop causes four new kinds of branch patterns to be generated in the async state machine. Three of these are to be eliminated, while the fourth is actually the "should I stay in the loop or not?" branch and is best left in a code coverage report. CecilSymbolHelper now eliminates the three branch patterns that need to be eliminated. There are tests both in CecilSymbolHelperTests as well as a new set of coverage tests in CoverageTests.AsyncForeach.cs.
1 parent 1ab6b17 commit 039e615

File tree

6 files changed

+377
-1
lines changed

6 files changed

+377
-1
lines changed

Diff for: src/coverlet.core/Symbols/CecilSymbolHelper.cs

+162-1
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,166 @@ private bool SkipGeneratedBranchesForExceptionHandlers(MethodDefinition methodDe
396396
return _compilerGeneratedBranchesToExclude[methodDefinition.FullName].Contains(instruction.Offset);
397397
}
398398

399+
private bool SkipGeneratedBranchesForAwaitForeach(List<Instruction> instructions, Instruction instruction)
400+
{
401+
// An "await foreach" causes four additional branches to be generated
402+
// by the compiler. We want to skip the last three, but we want to
403+
// keep the first one.
404+
//
405+
// (1) At each iteration of the loop, a check that there is another
406+
// item in the sequence. This is a branch that we want to keep,
407+
// because it's basically "should we stay in the loop or not?",
408+
// which is germane to code coverage testing.
409+
// (2) A check near the end for whether the IAsyncEnumerator was ever
410+
// obtained, so it can be disposed.
411+
// (3) A check for whether an exception was thrown in a most recent
412+
// loop iteration.
413+
// (4) A check for whether the exception thrown in the most recent
414+
// loop iteration has (at least) the type System.Exception.
415+
//
416+
// If we're looking at any of three the last three of those four
417+
// branches, we should be skipping it.
418+
419+
int currentIndex = instructions.BinarySearch(instruction, new InstructionByOffsetComparer());
420+
421+
return SkipGeneratedBranchForAwaitForeach_CheckForAsyncEnumerator(instructions, instruction, currentIndex) ||
422+
SkipGeneratedBranchForAwaitForeach_CheckIfExceptionThrown(instructions, instruction, currentIndex) ||
423+
SkipGeneratedBranchForAwaitForeach_CheckThrownExceptionType(instructions, instruction, currentIndex);
424+
}
425+
426+
// The pattern for the "should we stay in the loop or not?", which we don't
427+
// want to skip (so we have no method to try to find it), looks like this:
428+
//
429+
// IL_0111: ldloca.s 4
430+
// IL_0113: call instance !0 valuetype [System.Private.CoreLib]System.Runtime.CompilerServices.ValueTaskAwaiter`1<bool>::GetResult()
431+
// IL_0118: brtrue IL_0047
432+
//
433+
// In Debug mode, there are additional things that happen in between
434+
// the "call" and branch, but it's the same idea either way: branch
435+
// if GetResult() returned true.
436+
437+
private bool SkipGeneratedBranchForAwaitForeach_CheckForAsyncEnumerator(List<Instruction> instructions, Instruction instruction, int currentIndex)
438+
{
439+
// We're looking for the following pattern, which checks whether a
440+
// compiler-generated field of type IAsyncEnumerator<> is null.
441+
//
442+
// IL_012c: ldfld class [System.Private.CoreLib]System.Collections.Generic.IAsyncEnumerator`1<int32> AwaitForeachStateMachine/'<AsyncAwait>d__0'::'<>7__wrap1'
443+
// IL_0131: brfalse.s IL_0196
444+
445+
if (instruction.OpCode != OpCodes.Brfalse &&
446+
instruction.OpCode != OpCodes.Brfalse_S)
447+
{
448+
return false;
449+
}
450+
451+
if (currentIndex >= 1 &&
452+
instructions[currentIndex - 1].OpCode == OpCodes.Ldfld &&
453+
instructions[currentIndex - 1].Operand is FieldDefinition field &&
454+
IsCompilerGenerated(field) && field.FieldType.FullName.StartsWith("System.Collections.Generic.IAsyncEnumerator"))
455+
{
456+
return true;
457+
}
458+
459+
return false;
460+
}
461+
462+
private bool SkipGeneratedBranchForAwaitForeach_CheckIfExceptionThrown(List<Instruction> instructions, Instruction instruction, int currentIndex)
463+
{
464+
// Here, we want to find a pattern where we're checking whether a
465+
// compiler-generated field of type Object is null. To narrow our
466+
// search down and reduce the odds of false positives, we'll also
467+
// expect a call to GetResult() to precede the loading of the field's
468+
// value. The basic pattern looks like this:
469+
//
470+
// IL_018f: ldloca.s 2
471+
// IL_0191: call instance void [System.Private.CoreLib]System.Runtime.CompilerServices.ValueTaskAwaiter::GetResult()
472+
// IL_0196: ldarg.0
473+
// IL_0197: ldfld object AwaitForeachStateMachine/'<AsyncAwait>d__0'::'<>7__wrap2'
474+
// IL_019c: stloc.s 6
475+
// IL_019e: ldloc.s 6
476+
// IL_01a0: brfalse.s IL_01b9
477+
//
478+
// Variants are possible (e.g., a "dup" instruction instead of a
479+
// "stloc.s" and "ldloc.s" pair), so we'll just look for the
480+
// highlights.
481+
482+
if (instruction.OpCode != OpCodes.Brfalse &&
483+
instruction.OpCode != OpCodes.Brfalse_S)
484+
{
485+
return false;
486+
}
487+
488+
// We expect the field to be loaded no more than thre instructions before
489+
// the branch, so that's how far we're willing to search for it.
490+
int minFieldIndex = Math.Max(0, currentIndex - 3);
491+
492+
for (int i = currentIndex - 1; i >= minFieldIndex; --i)
493+
{
494+
if (instructions[i].OpCode == OpCodes.Ldfld &&
495+
instructions[i].Operand is FieldDefinition field &&
496+
IsCompilerGenerated(field) && field.FieldType.FullName == "System.Object")
497+
{
498+
// We expect the call to GetResult() to be no more than three
499+
// instructions before the loading of the field's value.
500+
int minCallIndex = Math.Max(0, i - 3);
501+
502+
for (int j = i - 1; j >= minCallIndex; --j)
503+
{
504+
if (instructions[j].OpCode == OpCodes.Call &&
505+
instructions[j].Operand is MethodReference callRef &&
506+
callRef.DeclaringType.FullName.StartsWith("System.Runtime.CompilerServices") &&
507+
callRef.DeclaringType.FullName.Contains("TaskAwait") &&
508+
callRef.Name == "GetResult")
509+
{
510+
return true;
511+
}
512+
}
513+
}
514+
}
515+
516+
return false;
517+
}
518+
519+
private bool SkipGeneratedBranchForAwaitForeach_CheckThrownExceptionType(List<Instruction> instructions, Instruction instruction, int currentIndex)
520+
{
521+
// In this case, we're looking for a branch generated by the compiler to
522+
// check whether a previously-thrown exception has (at least) the type
523+
// System.Exception, the pattern for which looks like this:
524+
//
525+
// IL_01db: ldloc.s 7
526+
// IL_01dd: isinst [System.Private.CoreLib]System.Exception
527+
// IL_01e2: stloc.s 9
528+
// IL_01e4: ldloc.s 9
529+
// IL_01e6: brtrue.s IL_01eb
530+
//
531+
// Once again, variants are possible here, such as a "dup" instruction in
532+
// place of the "stloc.s" and "ldloc.s" pair, and we'll reduce the odds of
533+
// a false positive by requiring a "ldloc.s" instruction to precede the
534+
// "isinst" instruction.
535+
536+
if (instruction.OpCode != OpCodes.Brtrue &&
537+
instruction.OpCode != OpCodes.Brtrue_S)
538+
{
539+
return false;
540+
}
541+
542+
int minTypeCheckIndex = Math.Max(1, currentIndex - 3);
543+
544+
for (int i = currentIndex - 1; i >= minTypeCheckIndex; --i)
545+
{
546+
if (instructions[i].OpCode == OpCodes.Isinst &&
547+
instructions[i].Operand is TypeReference typeRef &&
548+
typeRef.FullName == "System.Exception" &&
549+
(instructions[i - 1].OpCode == OpCodes.Ldloc ||
550+
instructions[i - 1].OpCode == OpCodes.Ldloc_S))
551+
{
552+
return true;
553+
}
554+
}
555+
556+
return false;
557+
}
558+
399559
// https://github.com/dotnet/roslyn/blob/master/docs/compilers/CSharp/Expression%20Breakpoints.md
400560
private bool SkipExpressionBreakpointsBranches(Instruction instruction) => instruction.Previous is not null && instruction.Previous.OpCode == OpCodes.Ldc_I4 &&
401561
instruction.Previous.Operand is int operandValue && operandValue == 1 &&
@@ -461,7 +621,8 @@ public IReadOnlyList<BranchPoint> GetBranchPoints(MethodDefinition methodDefinit
461621
if (isAsyncStateMachineMoveNext)
462622
{
463623
if (SkipGeneratedBranchesForExceptionHandlers(methodDefinition, instruction, instructions) ||
464-
SkipGeneratedBranchForExceptionRethrown(instructions, instruction))
624+
SkipGeneratedBranchForExceptionRethrown(instructions, instruction) ||
625+
SkipGeneratedBranchesForAwaitForeach(instructions, instruction))
465626
{
466627
continue;
467628
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
using System.Collections.Generic;
2+
using System.IO;
3+
using System.Threading.Tasks;
4+
5+
using Coverlet.Core.Samples.Tests;
6+
using Coverlet.Tests.Xunit.Extensions;
7+
using Xunit;
8+
9+
namespace Coverlet.Core.Tests
10+
{
11+
public partial class CoverageTests
12+
{
13+
async private static IAsyncEnumerable<T> ToAsync<T>(IEnumerable<T> values)
14+
{
15+
foreach (T value in values)
16+
{
17+
yield return await Task.FromResult(value);
18+
}
19+
}
20+
21+
[Fact]
22+
public void AsyncForeach()
23+
{
24+
string path = Path.GetTempFileName();
25+
try
26+
{
27+
FunctionExecutor.Run(async (string[] pathSerialize) =>
28+
{
29+
CoveragePrepareResult coveragePrepareResult = await TestInstrumentationHelper.Run<AsyncForeach>(instance =>
30+
{
31+
int res = ((ValueTask<int>)instance.SumWithATwist(ToAsync(new int[] { 1, 2, 3, 4, 5 }))).GetAwaiter().GetResult();
32+
res += ((ValueTask<int>)instance.Sum(ToAsync(new int[] { 1, 2, 3 }))).GetAwaiter().GetResult();
33+
res += ((ValueTask<int>)instance.SumEmpty()).GetAwaiter().GetResult();
34+
35+
return Task.CompletedTask;
36+
}, persistPrepareResultToFile: pathSerialize[0]);
37+
return 0;
38+
}, new string[] { path });
39+
40+
TestInstrumentationHelper.GetCoverageResult(path)
41+
.Document("Instrumentation.AsyncForeach.cs")
42+
.AssertLinesCovered(BuildConfiguration.Debug,
43+
// SumWithATwist(IAsyncEnumerable<int>)
44+
// Apparently due to entering and exiting the async state machine, line 17
45+
// (the top of an "await foreach" loop) is reached three times *plus* twice
46+
// per loop iteration. So, in this case, with five loop iterations, we end
47+
// up with 3 + 5 * 2 = 13 hits.
48+
(14, 1), (15, 1), (17, 13), (18, 5), (19, 5), (20, 5), (21, 5), (22, 5),
49+
(24, 0), (25, 0), (26, 0), (27, 5), (29, 1), (30, 1),
50+
// Sum(IAsyncEnumerable<int>)
51+
(34, 1), (35, 1), (37, 9), (38, 3), (39, 3), (40, 3), (42, 1), (43, 1),
52+
// SumEmpty()
53+
(47, 1), (48, 1), (50, 3), (51, 0), (52, 0), (53, 0), (55, 1), (56, 1)
54+
)
55+
.AssertBranchesCovered(BuildConfiguration.Debug,
56+
// SumWithATwist(IAsyncEnumerable<int>)
57+
(17, 2, 1), (17, 3, 5), (19, 0, 5), (19, 1, 0),
58+
// Sum(IAsyncEnumerable<int>)
59+
(37, 0, 1), (37, 1, 3),
60+
// SumEmpty()
61+
// If we never entered the loop, that's a branch not taken, which is
62+
// what we want to see.
63+
(50, 0, 1), (50, 1, 0)
64+
)
65+
.ExpectedTotalNumberOfBranches(BuildConfiguration.Debug, 4);
66+
}
67+
finally
68+
{
69+
File.Delete(path);
70+
}
71+
}
72+
}
73+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Remember to use full name because adding new using directives change line numbers
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
9+
namespace Coverlet.Core.Samples.Tests
10+
{
11+
public class AsyncForeach
12+
{
13+
async public ValueTask<int> SumWithATwist(IAsyncEnumerable<int> ints)
14+
{
15+
int sum = 0;
16+
17+
await foreach (int i in ints)
18+
{
19+
if (i > 0)
20+
{
21+
sum += i;
22+
}
23+
else
24+
{
25+
sum = 0;
26+
}
27+
}
28+
29+
return sum;
30+
}
31+
32+
33+
async public ValueTask<int> Sum(IAsyncEnumerable<int> ints)
34+
{
35+
int sum = 0;
36+
37+
await foreach (int i in ints)
38+
{
39+
sum += i;
40+
}
41+
42+
return sum;
43+
}
44+
45+
46+
async public ValueTask<int> SumEmpty()
47+
{
48+
int sum = 0;
49+
50+
await foreach (int i in AsyncEnumerable.Empty<int>())
51+
{
52+
sum += i;
53+
}
54+
55+
return sum;
56+
}
57+
}
58+
}

Diff for: test/coverlet.core.tests/Samples/Samples.cs

+33
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,39 @@ async public ValueTask AsyncAwait()
197197
}
198198
}
199199

200+
public class AwaitForeachStateMachine
201+
{
202+
async public ValueTask AsyncAwait(IAsyncEnumerable<int> ints)
203+
{
204+
await foreach (int i in ints)
205+
{
206+
await default(ValueTask);
207+
}
208+
}
209+
}
210+
211+
public class AwaitForeachStateMachine_WithBranches
212+
{
213+
async public ValueTask<int> SumWithATwist(IAsyncEnumerable<int> ints)
214+
{
215+
int sum = 0;
216+
217+
await foreach (int i in ints)
218+
{
219+
if (i > 0)
220+
{
221+
sum += i;
222+
}
223+
else
224+
{
225+
sum = 0;
226+
}
227+
}
228+
229+
return sum;
230+
}
231+
}
232+
200233
[ExcludeFromCoverage]
201234
public class ClassExcludedByCoverletCodeCoverageAttr
202235
{

0 commit comments

Comments
 (0)