Skip to content

Commit 4b7f922

Browse files
committed
Make sure we report only once per product for entire solution
Since many projects can use the same package in a solution, it makes no sense to report multiple times the same diagnostic asking for funding. It would just be super annoying. So make it process-wide and singleton. We use a mutex+memory-mapped file which lives alongside the whole process, since individual projects may be built separately and cause duplicate reporting even if we use use static or AppDomain-stored state.
1 parent 5009784 commit 4b7f922

File tree

3 files changed

+46
-12
lines changed

3 files changed

+46
-12
lines changed

src/SponsorLink/Library/Library.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,8 @@
1818
<ProjectReference Include="..\Analyzer\Analyzer.csproj" ReferenceOutputAssembly="false" OutputType="Analyzer" />
1919
</ItemGroup>
2020

21+
<Target Name="Version" AfterTargets="GetPackageContents" DependsOnTargets="GetPackageTargetPath">
22+
<Message Importance="high" Text="$(MSBuildProjectName) &gt; $(PackageTargetPath)" />
23+
</Target>
24+
2125
</Project>

src/SponsorLink/SponsorLink/DiagnosticsManager.cs

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44
using System.Collections.Concurrent;
55
using System.Collections.Generic;
66
using System.Collections.Immutable;
7+
using System.Diagnostics;
78
using System.Globalization;
89
using System.IO;
10+
using System.IO.MemoryMappedFiles;
911
using System.Linq;
12+
using System.Threading;
1013
using Humanizer;
1114
using Humanizer.Localisation;
1215
using Microsoft.CodeAnalysis;
@@ -21,6 +24,8 @@ namespace Devlooped.Sponsors;
2124
/// </summary>
2225
class DiagnosticsManager
2326
{
27+
static readonly Guid appDomainDiagnosticsKey = new(0x8d0e2670, 0xe6c4, 0x45c8, 0x81, 0xba, 0x5a, 0x36, 0x81, 0xd3, 0x65, 0x3e);
28+
2429
public static Dictionary<SponsorStatus, DiagnosticDescriptor> KnownDescriptors { get; } = new()
2530
{
2631
// Requires:
@@ -36,17 +41,31 @@ class DiagnosticsManager
3641
/// Acceses the diagnostics dictionary for the current <see cref="AppDomain"/>.
3742
/// </summary>
3843
ConcurrentDictionary<string, Diagnostic> Diagnostics
39-
=> AppDomainDictionary.Get<ConcurrentDictionary<string, Diagnostic>>(nameof(Diagnostics));
44+
=> AppDomainDictionary.Get<ConcurrentDictionary<string, Diagnostic>>(appDomainDiagnosticsKey.ToString());
4045

4146
/// <summary>
4247
/// Attemps to remove a diagnostic for the given product.
4348
/// </summary>
4449
/// <param name="product">The product diagnostic that might have been pushed previously.</param>
4550
/// <returns>The removed diagnostic, or <see langword="null" /> if none was previously pushed.</returns>
46-
public Diagnostic? Pop(string product)
51+
public void ReportOnce(Action<Diagnostic> report, string product = Funding.Product)
4752
{
48-
Diagnostics.TryRemove(product, out var diagnostic);
49-
return diagnostic;
53+
if (Diagnostics.TryRemove(product, out var diagnostic))
54+
{
55+
// Ensure only one such diagnostic is reported per product for the entire process,
56+
// so that we can avoid polluting the error list with duplicates across multiple projects.
57+
var id = string.Concat(Process.GetCurrentProcess().Id, product, diagnostic.Id);
58+
using var mutex = new Mutex(false, "mutex" + id);
59+
mutex.WaitOne();
60+
using var mmf = MemoryMappedFile.CreateOrOpen(id, 1);
61+
using var accessor = mmf.CreateViewAccessor();
62+
if (accessor.ReadByte(0) == 0)
63+
{
64+
accessor.Write(0, 1);
65+
report(diagnostic);
66+
Tracing.Trace($"👈{diagnostic.Severity.ToString().ToLowerInvariant()}:{Process.GetCurrentProcess().Id}:{Process.GetCurrentProcess().ProcessName}:{product}:{diagnostic.Id}");
67+
}
68+
}
5069
}
5170

5271
/// <summary>
@@ -103,7 +122,7 @@ SponsorStatus GetOrSetStatus(Func<ImmutableArray<AdditionalText>> getManifests)
103122
claims.GetExpiration() is not DateTime exp)
104123
{
105124
// report unknown, either unparsed manifest or one with no expiration (which we never emit).
106-
Push(Funding.Product, Diagnostic.Create(KnownDescriptors[SponsorStatus.Unknown], null,
125+
Push(Diagnostic.Create(KnownDescriptors[SponsorStatus.Unknown], null,
107126
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Unknown)),
108127
Funding.Product, Sponsorables.Keys.Humanize(Resources.Or)));
109128
return SponsorStatus.Unknown;
@@ -114,22 +133,22 @@ SponsorStatus GetOrSetStatus(Func<ImmutableArray<AdditionalText>> getManifests)
114133
if (exp.AddDays(Funding.Grace) < DateTime.Now)
115134
{
116135
// report expiring soon
117-
Push(Funding.Product, Diagnostic.Create(KnownDescriptors[SponsorStatus.Expiring], null,
136+
Push(Diagnostic.Create(KnownDescriptors[SponsorStatus.Expiring], null,
118137
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expiring))));
119138
return SponsorStatus.Expiring;
120139
}
121140
else
122141
{
123142
// report expired
124-
Push(Funding.Product, Diagnostic.Create(KnownDescriptors[SponsorStatus.Expired], null,
143+
Push(Diagnostic.Create(KnownDescriptors[SponsorStatus.Expired], null,
125144
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expired))));
126145
return SponsorStatus.Expired;
127146
}
128147
}
129148
else
130149
{
131150
// report sponsor
132-
Push(Funding.Product, Diagnostic.Create(KnownDescriptors[SponsorStatus.Sponsor], null,
151+
Push(Diagnostic.Create(KnownDescriptors[SponsorStatus.Sponsor], null,
133152
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Sponsor)),
134153
Funding.Product));
135154
return SponsorStatus.Sponsor;
@@ -140,11 +159,22 @@ SponsorStatus GetOrSetStatus(Func<ImmutableArray<AdditionalText>> getManifests)
140159
/// Pushes a diagnostic for the given product.
141160
/// </summary>
142161
/// <returns>The same diagnostic that was pushed, for chained invocations.</returns>
143-
Diagnostic Push(string product, Diagnostic diagnostic)
162+
Diagnostic Push(Diagnostic diagnostic, string product = Funding.Product)
144163
{
145164
// We only expect to get one warning per sponsorable+product
146165
// combination, and first one to set wins.
147-
Diagnostics.TryAdd(product, diagnostic);
166+
if (Diagnostics.TryAdd(product, diagnostic))
167+
{
168+
// Reset the process-wide flag for this diagnostic.
169+
var id = string.Concat(Process.GetCurrentProcess().Id, product, diagnostic.Id);
170+
using var mutex = new Mutex(false, "mutex" + id);
171+
mutex.WaitOne();
172+
using var mmf = MemoryMappedFile.CreateOrOpen(id, 1);
173+
using var accessor = mmf.CreateViewAccessor();
174+
accessor.Write(0, 0);
175+
Tracing.Trace($"👉{diagnostic.Severity.ToString().ToLowerInvariant()}:{Process.GetCurrentProcess().Id}:{Process.GetCurrentProcess().ProcessName}:{product}:{diagnostic.Id}");
176+
}
177+
148178
return diagnostic;
149179
}
150180

src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public override void Initialize(AnalysisContext context)
4545
// NOTE: for multiple projects with the same product name, we only report one diagnostic,
4646
// so it's expected to NOT get a diagnostic back. Also, we don't want to report
4747
// multiple diagnostics for each project in a solution that uses the same product.
48-
if (Diagnostics.Pop(Funding.Product) is Diagnostic diagnostic)
48+
Diagnostics.ReportOnce(diagnostic =>
4949
{
5050
// For unknown (never sync'ed), only report if install grace period is over
5151
if (status == SponsorStatus.Unknown)
@@ -80,7 +80,7 @@ public override void Initialize(AnalysisContext context)
8080
}
8181

8282
ctx.ReportDiagnostic(diagnostic);
83-
}
83+
});
8484
});
8585
}
8686
});

0 commit comments

Comments
 (0)