Skip to content

Commit

Permalink
Make sure we report only once per product for entire solution
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
kzu committed Jun 29, 2024
1 parent 5009784 commit 4b7f922
Show file tree
Hide file tree
Showing 3 changed files with 46 additions and 12 deletions.
4 changes: 4 additions & 0 deletions src/SponsorLink/Library/Library.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,8 @@
<ProjectReference Include="..\Analyzer\Analyzer.csproj" ReferenceOutputAssembly="false" OutputType="Analyzer" />
</ItemGroup>

<Target Name="Version" AfterTargets="GetPackageContents" DependsOnTargets="GetPackageTargetPath">
<Message Importance="high" Text="$(MSBuildProjectName) &gt; $(PackageTargetPath)" />
</Target>

</Project>
50 changes: 40 additions & 10 deletions src/SponsorLink/SponsorLink/DiagnosticsManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.IO.MemoryMappedFiles;
using System.Linq;
using System.Threading;
using Humanizer;
using Humanizer.Localisation;
using Microsoft.CodeAnalysis;
Expand All @@ -21,6 +24,8 @@ namespace Devlooped.Sponsors;
/// </summary>
class DiagnosticsManager
{
static readonly Guid appDomainDiagnosticsKey = new(0x8d0e2670, 0xe6c4, 0x45c8, 0x81, 0xba, 0x5a, 0x36, 0x81, 0xd3, 0x65, 0x3e);

public static Dictionary<SponsorStatus, DiagnosticDescriptor> KnownDescriptors { get; } = new()
{
// Requires:
Expand All @@ -36,17 +41,31 @@ class DiagnosticsManager
/// Acceses the diagnostics dictionary for the current <see cref="AppDomain"/>.
/// </summary>
ConcurrentDictionary<string, Diagnostic> Diagnostics
=> AppDomainDictionary.Get<ConcurrentDictionary<string, Diagnostic>>(nameof(Diagnostics));
=> AppDomainDictionary.Get<ConcurrentDictionary<string, Diagnostic>>(appDomainDiagnosticsKey.ToString());

/// <summary>
/// Attemps to remove a diagnostic for the given product.
/// </summary>
/// <param name="product">The product diagnostic that might have been pushed previously.</param>
/// <returns>The removed diagnostic, or <see langword="null" /> if none was previously pushed.</returns>
public Diagnostic? Pop(string product)
public void ReportOnce(Action<Diagnostic> report, string product = Funding.Product)
{
Diagnostics.TryRemove(product, out var diagnostic);
return diagnostic;
if (Diagnostics.TryRemove(product, out var diagnostic))
{
// Ensure only one such diagnostic is reported per product for the entire process,
// so that we can avoid polluting the error list with duplicates across multiple projects.
var id = string.Concat(Process.GetCurrentProcess().Id, product, diagnostic.Id);
using var mutex = new Mutex(false, "mutex" + id);
mutex.WaitOne();
using var mmf = MemoryMappedFile.CreateOrOpen(id, 1);
using var accessor = mmf.CreateViewAccessor();
if (accessor.ReadByte(0) == 0)
{
accessor.Write(0, 1);
report(diagnostic);
Tracing.Trace($"👈{diagnostic.Severity.ToString().ToLowerInvariant()}:{Process.GetCurrentProcess().Id}:{Process.GetCurrentProcess().ProcessName}:{product}:{diagnostic.Id}");
}
}
}

/// <summary>
Expand Down Expand Up @@ -103,7 +122,7 @@ SponsorStatus GetOrSetStatus(Func<ImmutableArray<AdditionalText>> getManifests)
claims.GetExpiration() is not DateTime exp)
{
// report unknown, either unparsed manifest or one with no expiration (which we never emit).
Push(Funding.Product, Diagnostic.Create(KnownDescriptors[SponsorStatus.Unknown], null,
Push(Diagnostic.Create(KnownDescriptors[SponsorStatus.Unknown], null,
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Unknown)),
Funding.Product, Sponsorables.Keys.Humanize(Resources.Or)));
return SponsorStatus.Unknown;
Expand All @@ -114,22 +133,22 @@ SponsorStatus GetOrSetStatus(Func<ImmutableArray<AdditionalText>> getManifests)
if (exp.AddDays(Funding.Grace) < DateTime.Now)
{
// report expiring soon
Push(Funding.Product, Diagnostic.Create(KnownDescriptors[SponsorStatus.Expiring], null,
Push(Diagnostic.Create(KnownDescriptors[SponsorStatus.Expiring], null,
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expiring))));
return SponsorStatus.Expiring;
}
else
{
// report expired
Push(Funding.Product, Diagnostic.Create(KnownDescriptors[SponsorStatus.Expired], null,
Push(Diagnostic.Create(KnownDescriptors[SponsorStatus.Expired], null,
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expired))));
return SponsorStatus.Expired;
}
}
else
{
// report sponsor
Push(Funding.Product, Diagnostic.Create(KnownDescriptors[SponsorStatus.Sponsor], null,
Push(Diagnostic.Create(KnownDescriptors[SponsorStatus.Sponsor], null,
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Sponsor)),
Funding.Product));
return SponsorStatus.Sponsor;
Expand All @@ -140,11 +159,22 @@ SponsorStatus GetOrSetStatus(Func<ImmutableArray<AdditionalText>> getManifests)
/// Pushes a diagnostic for the given product.
/// </summary>
/// <returns>The same diagnostic that was pushed, for chained invocations.</returns>
Diagnostic Push(string product, Diagnostic diagnostic)
Diagnostic Push(Diagnostic diagnostic, string product = Funding.Product)
{
// We only expect to get one warning per sponsorable+product
// combination, and first one to set wins.
Diagnostics.TryAdd(product, diagnostic);
if (Diagnostics.TryAdd(product, diagnostic))
{
// Reset the process-wide flag for this diagnostic.
var id = string.Concat(Process.GetCurrentProcess().Id, product, diagnostic.Id);
using var mutex = new Mutex(false, "mutex" + id);
mutex.WaitOne();
using var mmf = MemoryMappedFile.CreateOrOpen(id, 1);
using var accessor = mmf.CreateViewAccessor();
accessor.Write(0, 0);
Tracing.Trace($"👉{diagnostic.Severity.ToString().ToLowerInvariant()}:{Process.GetCurrentProcess().Id}:{Process.GetCurrentProcess().ProcessName}:{product}:{diagnostic.Id}");
}

return diagnostic;
}

Expand Down
4 changes: 2 additions & 2 deletions src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public override void Initialize(AnalysisContext context)
// NOTE: for multiple projects with the same product name, we only report one diagnostic,
// so it's expected to NOT get a diagnostic back. Also, we don't want to report
// multiple diagnostics for each project in a solution that uses the same product.
if (Diagnostics.Pop(Funding.Product) is Diagnostic diagnostic)
Diagnostics.ReportOnce(diagnostic =>
{
// For unknown (never sync'ed), only report if install grace period is over
if (status == SponsorStatus.Unknown)
Expand Down Expand Up @@ -80,7 +80,7 @@ public override void Initialize(AnalysisContext context)
}

ctx.ReportDiagnostic(diagnostic);
}
});
});
}
});
Expand Down

0 comments on commit 4b7f922

Please sign in to comment.