Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -191,34 +191,39 @@ public void MarkSourcesWithStatus(IEnumerable<string?>? sources, DiscoveryStatus
continue;
}

_sourcesWithDiscoveryStatus.AddOrUpdate(source,
_ =>
MarkSourceWithStatus(source, status);
}
}

private void MarkSourceWithStatus(string source, DiscoveryStatus status)
{
_sourcesWithDiscoveryStatus.AddOrUpdate(source,
Comment on lines +198 to +200
_ =>
{
if (status != DiscoveryStatus.NotDiscovered)
{
if (status != DiscoveryStatus.NotDiscovered)
{
EqtTrace.Warning($"DiscoveryDataAggregator.MarkSourcesWithStatus: Undiscovered {source} added with status: '{status}'.");
}
else
{
EqtTrace.Verbose($"DiscoveryDataAggregator.MarkSourcesWithStatus: Adding {source} with status: '{status}'.");
}

return status;
},
(_, previousStatus) =>
EqtTrace.Warning($"DiscoveryDataAggregator.MarkSourceWithStatus: Undiscovered {source} added with status: '{status}'.");

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

nit: [Error Reporting & Diagnostic Clarity] All per-source trace messages changed from MarkSourcesWithStatus (plural) to MarkSourceWithStatus (singular). The new name is factually more accurate since the messages now live in the private helper, but it's a format change that would break any grep-based log analysis or support runbooks that match on the old prefix. No test currently asserts on these strings so nothing breaks in CI, but worth noting for ops / support docs.

}
else
{
if (previousStatus == DiscoveryStatus.FullyDiscovered && status != DiscoveryStatus.FullyDiscovered
|| previousStatus == DiscoveryStatus.PartiallyDiscovered && (status == DiscoveryStatus.NotDiscovered || status == DiscoveryStatus.SkippedDiscovery))
{
EqtTrace.Warning($"DiscoveryDataAggregator.MarkSourcesWithStatus: Downgrading source {source} status from '{previousStatus}' to '{status}'.");
}
else if (previousStatus != status)
{
EqtTrace.Verbose($"DiscoveryDataAggregator.MarkSourcesWithStatus: Upgrading {source} status from '{previousStatus}' to '{status}'.");
}
return status;
});
}
EqtTrace.Verbose($"DiscoveryDataAggregator.MarkSourceWithStatus: Adding {source} with status: '{status}'.");
}

return status;
},
(_, previousStatus) =>
{
if (previousStatus == DiscoveryStatus.FullyDiscovered && status != DiscoveryStatus.FullyDiscovered
|| previousStatus == DiscoveryStatus.PartiallyDiscovered && (status == DiscoveryStatus.NotDiscovered || status == DiscoveryStatus.SkippedDiscovery))
{
EqtTrace.Warning($"DiscoveryDataAggregator.MarkSourceWithStatus: Downgrading source {source} status from '{previousStatus}' to '{status}'.");
}
else if (previousStatus != status)
{
EqtTrace.Verbose($"DiscoveryDataAggregator.MarkSourceWithStatus: Upgrading {source} status from '{previousStatus}' to '{status}'.");
}
return status;
});
}

/// <summary>
Expand All @@ -245,20 +250,26 @@ public void MarkSourcesWithStatus(IEnumerable<string?>? sources, DiscoveryStatus

foreach (var testCase in testCases)
{
if (_isMessageSent == 1)
{
EqtTrace.Verbose("DiscoveryDataAggregator.MarkSourcesBasedOnDiscoveredTestCases: Message was already sent so skipping remaining source updates.");
return previousSource;
}

var currentSource = testCase.Source;

// We rely on the fact that sources are processed in a sequential way, which
// means that when we receive a different source than the previous, we can
// assume that the previous source was fully discovered.
if (previousSource is null || previousSource == currentSource)
{
MarkSourcesWithStatus(new[] { currentSource }, DiscoveryStatus.PartiallyDiscovered);
MarkSourceWithStatus(currentSource, DiscoveryStatus.PartiallyDiscovered);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Parallel Execution & Scheduling Safety] The _isMessageSent guard granularity regressed from per-test-case to per-batch.

Before this PR, each MarkSourcesWithStatus(new[] { ... }, ...) call checked _isMessageSent at entry. So if TryAggregateIsMessageSent() fired on a concurrent thread mid-batch, the very next iteration caught it.

Now the guard only lives at the top of MarkSourcesBasedOnDiscoveredTestCases. If TryAggregateIsMessageSent() races in after the method enters the loop, the entire remaining batch continues writing to _sourcesWithDiscoveryStatus even though ParallelDiscoveryEventsHandler may already be executing GetSourcesWithStatus() on lines 89–92.

Practical sequence:

  1. Thread A passes the top-level guard (_isMessageSent == 0) and begins iterating a batch
  2. Thread B calls TryAggregateIsMessageSent() (sets _isMessageSent = 1) then immediately calls GetSourcesWithStatus() to build the final report
  3. Thread A continues MarkSourceWithStatus() for every remaining test case in the batch

Those writes are "post-finalization" updates that weren't reflected in Thread B's snapshot. A source that should have been upgraded from PartiallyDiscoveredFullyDiscovered mid-batch may appear as PartiallyDiscovered in the final report, which then incorrectly triggers the "discovery aborted" path at ParallelDiscoveryEventsHandler line 106.

The ConcurrentDictionary doesn't corrupt, but the "Identical observable behaviour" and "No impact on thread safety" claims in the PR description aren't accurate for this concurrent scenario.

The race existed before (just narrower), so this is not a newly introduced bug — but it is a regression in the guard's effectiveness. The simplest fix is a mid-loop check:

Suggested change
MarkSourceWithStatus(currentSource, DiscoveryStatus.PartiallyDiscovered);
foreach (var testCase in testCases)
{
if (_isMessageSent == 1)
{
return previousSource;
}
var currentSource = testCase.Source;

Alternatively, document that the guard was intentionally coarsened to per-batch and add a test for MarkSourcesBasedOnDiscoveredTestCases + TryAggregateIsMessageSent to pin the behaviour (analogous to MarkSourcesWithStatus_AfterMessageSent_ShouldSkipUpdate).

}
else if (currentSource != previousSource)
{
EqtTrace.Verbose($"DiscoveryDataAggregator.MarkSourcesBasedOnDiscoveredTestCases: Discovered test source changed from {previousSource} to {currentSource}.");
MarkSourcesWithStatus(new[] { previousSource }, DiscoveryStatus.FullyDiscovered);
MarkSourcesWithStatus(new[] { currentSource }, DiscoveryStatus.PartiallyDiscovered);
MarkSourceWithStatus(previousSource, DiscoveryStatus.FullyDiscovered);
MarkSourceWithStatus(currentSource, DiscoveryStatus.PartiallyDiscovered);
}

previousSource = currentSource;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Collections.Generic;

using Microsoft.VisualStudio.TestPlatform.CrossPlatEngine.Client.Parallel;
using Microsoft.VisualStudio.TestPlatform.ObjectModel;
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Engine;
using Microsoft.VisualStudio.TestTools.UnitTesting;

Expand Down Expand Up @@ -125,4 +126,25 @@ public void MarkSourcesWithStatus_AfterMessageSent_ShouldSkipUpdate()
var result = aggregator.GetSourcesWithStatus(DiscoveryStatus.NotDiscovered);
Assert.HasCount(1, result);
}

// Pins the _isMessageSent guard for MarkSourcesBasedOnDiscoveredTestCases
[TestMethod]
public void MarkSourcesBasedOnDiscoveredTestCases_AfterMessageSent_ShouldSkipUpdate()
{
var aggregator = new DiscoveryDataAggregator();
aggregator.MarkSourcesWithStatus(new[] { "test.dll" }, DiscoveryStatus.NotDiscovered);

// Mark message as sent before the batch arrives
aggregator.TryAggregateIsMessageSent();

// This batch should be entirely skipped since message was already sent
var testCases = new[] { new TestCase { Source = "test.dll" } };
aggregator.MarkSourcesBasedOnDiscoveredTestCases(null, testCases);

// Status should still be NotDiscovered, not PartiallyDiscovered
var notDiscovered = aggregator.GetSourcesWithStatus(DiscoveryStatus.NotDiscovered);
var partiallyDiscovered = aggregator.GetSourcesWithStatus(DiscoveryStatus.PartiallyDiscovered);
Assert.HasCount(1, notDiscovered);
Assert.IsEmpty(partiallyDiscovered);
}
}
Loading