浏览代码

Fix sync disposal of async-created IAsyncDisposable objects (#14755)

evan314159 1 天之前
父节点
当前提交
2618a5fba2

+ 5 - 0
Directory.Build.props

@@ -19,4 +19,9 @@
     <AdditionalFiles Include="$(MSBuildThisFileDirectory)/stylecop.json" />
   </ItemGroup>
 
+  <!-- Custom Analyzers -->
+  <ItemGroup Condition=" '$(MSBuildProjectName)' != 'Jellyfin.CodeAnalysis' ">
+    <ProjectReference Include="$(MSBuildThisFileDirectory)src/Jellyfin.CodeAnalysis/Jellyfin.CodeAnalysis.csproj" OutputItemType="Analyzer" />
+  </ItemGroup>
+
 </Project>

+ 5 - 2
Directory.Packages.props

@@ -29,6 +29,9 @@
     <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.9" />
     <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.9" />
     <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
+    <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.12.0" />
+    <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
+    <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
     <PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.9" />
     <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9" />
     <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.9" />
@@ -70,7 +73,7 @@
     <PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
     <PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
     <PackageVersion Include="SharpFuzz" Version="2.2.0" />
-     <!-- Pinned to 3.116.1 because https://github.com/jellyfin/jellyfin/pull/14255 -->
+    <!-- Pinned to 3.116.1 because https://github.com/jellyfin/jellyfin/pull/14255 -->
     <PackageVersion Include="SkiaSharp" Version="3.116.1" />
     <PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.116.1" />
     <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" />
@@ -93,4 +96,4 @@
     <PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
     <PackageVersion Include="xunit" Version="2.9.3" />
   </ItemGroup>
-</Project>
+</Project>

+ 8 - 5
Jellyfin.Server.Implementations/Item/KeyframeRepository.cs

@@ -55,11 +55,14 @@ public class KeyframeRepository : IKeyframeRepository
     public async Task SaveKeyframeDataAsync(Guid itemId, MediaEncoding.Keyframes.KeyframeData data, CancellationToken cancellationToken)
     {
         using var context = _dbProvider.CreateDbContext();
-        using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
-        await context.KeyframeData.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
-        await context.KeyframeData.AddAsync(Map(data, itemId), cancellationToken).ConfigureAwait(false);
-        await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
-        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
+        var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
+        await using (transaction.ConfigureAwait(false))
+        {
+            await context.KeyframeData.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+            await context.KeyframeData.AddAsync(Map(data, itemId), cancellationToken).ConfigureAwait(false);
+            await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+            await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
+        }
     }
 
     /// <inheritdoc />

+ 107 - 93
Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs

@@ -68,87 +68,89 @@ public class MediaSegmentManager : IMediaSegmentManager
             return;
         }
 
-        using var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
-
-        _logger.LogDebug("Start media segment extraction for {MediaPath} with {CountProviders} providers enabled", baseItem.Path, providers.Count);
-
-        if (forceOverwrite)
-        {
-            // delete all existing media segments if forceOverwrite is set.
-            await db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
-        }
-
-        foreach (var provider in providers)
+        var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+        await using (db.ConfigureAwait(false))
         {
-            if (!await provider.Supports(baseItem).ConfigureAwait(false))
-            {
-                _logger.LogDebug("Media Segment provider {ProviderName} does not support item with path {MediaPath}", provider.Name, baseItem.Path);
-                continue;
-            }
+            _logger.LogDebug("Start media segment extraction for {MediaPath} with {CountProviders} providers enabled", baseItem.Path, providers.Count);
 
-            IQueryable<MediaSegment> existingSegments;
             if (forceOverwrite)
             {
-                existingSegments = Array.Empty<MediaSegment>().AsQueryable();
-            }
-            else
-            {
-                existingSegments = db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id) && e.SegmentProviderId == GetProviderId(provider.Name));
+                // delete all existing media segments if forceOverwrite is set.
+                await db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
             }
 
-            var requestItem = new MediaSegmentGenerationRequest()
+            foreach (var provider in providers)
             {
-                ItemId = baseItem.Id,
-                ExistingSegments = existingSegments.Select(e => Map(e)).ToArray()
-            };
+                if (!await provider.Supports(baseItem).ConfigureAwait(false))
+                {
+                    _logger.LogDebug("Media Segment provider {ProviderName} does not support item with path {MediaPath}", provider.Name, baseItem.Path);
+                    continue;
+                }
 
-            try
-            {
-                var segments = await provider.GetMediaSegments(requestItem, cancellationToken)
-                    .ConfigureAwait(false);
+                IQueryable<MediaSegment> existingSegments;
+                if (forceOverwrite)
+                {
+                    existingSegments = Array.Empty<MediaSegment>().AsQueryable();
+                }
+                else
+                {
+                    existingSegments = db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id) && e.SegmentProviderId == GetProviderId(provider.Name));
+                }
+
+                var requestItem = new MediaSegmentGenerationRequest()
+                {
+                    ItemId = baseItem.Id,
+                    ExistingSegments = existingSegments.Select(e => Map(e)).ToArray()
+                };
 
-                if (!forceOverwrite)
+                try
                 {
-                    var existingSegmentsList = existingSegments.ToArray(); // Cannot use requestItem's list, as the provider might tamper with its items.
-                    if (segments.Count == requestItem.ExistingSegments.Count && segments.All(e => existingSegmentsList.Any(f =>
+                    var segments = await provider.GetMediaSegments(requestItem, cancellationToken)
+                        .ConfigureAwait(false);
+
+                    if (!forceOverwrite)
+                    {
+                        var existingSegmentsList = existingSegments.ToArray(); // Cannot use requestItem's list, as the provider might tamper with its items.
+                        if (segments.Count == requestItem.ExistingSegments.Count && segments.All(e => existingSegmentsList.Any(f =>
+                        {
+                            return
+                                e.StartTicks == f.StartTicks &&
+                                e.EndTicks == f.EndTicks &&
+                                e.Type == f.Type;
+                        })))
+                        {
+                            _logger.LogDebug("Media Segment provider {ProviderName} did not modify any segments for {MediaPath}", provider.Name, baseItem.Path);
+                            continue;
+                        }
+
+                        // delete existing media segments that were re-generated.
+                        await existingSegments.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+                    }
+
+                    if (segments.Count == 0 && !requestItem.ExistingSegments.Any())
                     {
-                        return
-                            e.StartTicks == f.StartTicks &&
-                            e.EndTicks == f.EndTicks &&
-                            e.Type == f.Type;
-                    })))
+                        _logger.LogDebug("Media Segment provider {ProviderName} did not find any segments for {MediaPath}", provider.Name, baseItem.Path);
+                        continue;
+                    }
+                    else if (segments.Count == 0 && requestItem.ExistingSegments.Any())
                     {
-                        _logger.LogDebug("Media Segment provider {ProviderName} did not modify any segments for {MediaPath}", provider.Name, baseItem.Path);
+                        _logger.LogDebug("Media Segment provider {ProviderName} deleted all segments for {MediaPath}", provider.Name, baseItem.Path);
                         continue;
                     }
 
-                    // delete existing media segments that were re-generated.
-                    await existingSegments.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
-                }
-
-                if (segments.Count == 0 && !requestItem.ExistingSegments.Any())
-                {
-                    _logger.LogDebug("Media Segment provider {ProviderName} did not find any segments for {MediaPath}", provider.Name, baseItem.Path);
-                    continue;
-                }
-                else if (segments.Count == 0 && requestItem.ExistingSegments.Any())
-                {
-                    _logger.LogDebug("Media Segment provider {ProviderName} deleted all segments for {MediaPath}", provider.Name, baseItem.Path);
-                    continue;
+                    _logger.LogInformation("Media Segment provider {ProviderName} found {CountSegments} for {MediaPath}", provider.Name, segments.Count, baseItem.Path);
+                    var providerId = GetProviderId(provider.Name);
+                    foreach (var segment in segments)
+                    {
+                        segment.ItemId = baseItem.Id;
+                        await CreateSegmentAsync(segment, providerId).ConfigureAwait(false);
+                    }
                 }
-
-                _logger.LogInformation("Media Segment provider {ProviderName} found {CountSegments} for {MediaPath}", provider.Name, segments.Count, baseItem.Path);
-                var providerId = GetProviderId(provider.Name);
-                foreach (var segment in segments)
+                catch (Exception ex)
                 {
-                    segment.ItemId = baseItem.Id;
-                    await CreateSegmentAsync(segment, providerId).ConfigureAwait(false);
+                    _logger.LogError(ex, "Provider {ProviderName} failed to extract segments from {MediaPath}", provider.Name, baseItem.Path);
                 }
             }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex, "Provider {ProviderName} failed to extract segments from {MediaPath}", provider.Name, baseItem.Path);
-            }
         }
     }
 
@@ -157,24 +159,34 @@ public class MediaSegmentManager : IMediaSegmentManager
     {
         ArgumentOutOfRangeException.ThrowIfLessThan(mediaSegment.EndTicks, mediaSegment.StartTicks);
 
-        using var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
-        db.MediaSegments.Add(Map(mediaSegment, segmentProviderId));
-        await db.SaveChangesAsync().ConfigureAwait(false);
+        var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+        await using (db.ConfigureAwait(false))
+        {
+            db.MediaSegments.Add(Map(mediaSegment, segmentProviderId));
+            await db.SaveChangesAsync().ConfigureAwait(false);
+        }
+
         return mediaSegment;
     }
 
     /// <inheritdoc />
     public async Task DeleteSegmentAsync(Guid segmentId)
     {
-        using var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
-        await db.MediaSegments.Where(e => e.Id.Equals(segmentId)).ExecuteDeleteAsync().ConfigureAwait(false);
+        var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+        await using (db.ConfigureAwait(false))
+        {
+            await db.MediaSegments.Where(e => e.Id.Equals(segmentId)).ExecuteDeleteAsync().ConfigureAwait(false);
+        }
     }
 
     /// <inheritdoc />
     public async Task DeleteSegmentsAsync(Guid itemId, CancellationToken cancellationToken)
     {
-        using var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
-        await db.MediaSegments.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+        var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+        await using (db.ConfigureAwait(false))
+        {
+            await db.MediaSegments.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+        }
     }
 
     /// <inheritdoc />
@@ -186,36 +198,38 @@ public class MediaSegmentManager : IMediaSegmentManager
             return [];
         }
 
-        using var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
-
-        var query = db.MediaSegments
-            .Where(e => e.ItemId.Equals(item.Id));
-
-        if (typeFilter is not null)
+        var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+        await using (db.ConfigureAwait(false))
         {
-            query = query.Where(e => typeFilter.Contains(e.Type));
-        }
+            var query = db.MediaSegments
+                .Where(e => e.ItemId.Equals(item.Id));
 
-        if (filterByProvider)
-        {
-            var providerIds = _segmentProviders
-                .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name)))
-                .Select(f => GetProviderId(f.Name))
-                .ToArray();
-            if (providerIds.Length == 0)
+            if (typeFilter is not null)
             {
-                return [];
+                query = query.Where(e => typeFilter.Contains(e.Type));
             }
 
-            query = query.Where(e => providerIds.Contains(e.SegmentProviderId));
-        }
+            if (filterByProvider)
+            {
+                var providerIds = _segmentProviders
+                    .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name)))
+                    .Select(f => GetProviderId(f.Name))
+                    .ToArray();
+                if (providerIds.Length == 0)
+                {
+                    return [];
+                }
 
-        return query
-            .OrderBy(e => e.StartTicks)
-            .AsNoTracking()
-            .AsEnumerable()
-            .Select(Map)
-            .ToArray();
+                query = query.Where(e => providerIds.Contains(e.SegmentProviderId));
+            }
+
+            return query
+                .OrderBy(e => e.StartTicks)
+                .AsNoTracking()
+                .AsEnumerable()
+                .Select(Map)
+                .ToArray();
+        }
     }
 
     private static MediaSegmentDto Map(MediaSegment segment)

+ 11 - 8
Jellyfin.Server/Migrations/Routines/FixDates.cs

@@ -41,14 +41,17 @@ public class FixDates : IAsyncMigrationRoutine
     {
         if (!TimeZoneInfo.Local.Equals(TimeZoneInfo.Utc))
         {
-            using var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
-            var sw = Stopwatch.StartNew();
-
-            await FixBaseItemsAsync(context, sw, cancellationToken).ConfigureAwait(false);
-            sw.Reset();
-            await FixChaptersAsync(context, sw, cancellationToken).ConfigureAwait(false);
-            sw.Reset();
-            await FixBaseItemImageInfos(context, sw, cancellationToken).ConfigureAwait(false);
+            var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+            await using (context.ConfigureAwait(false))
+            {
+                var sw = Stopwatch.StartNew();
+
+                await FixBaseItemsAsync(context, sw, cancellationToken).ConfigureAwait(false);
+                sw.Reset();
+                await FixChaptersAsync(context, sw, cancellationToken).ConfigureAwait(false);
+                sw.Reset();
+                await FixBaseItemImageInfos(context, sw, cancellationToken).ConfigureAwait(false);
+            }
         }
     }
 

+ 7 - 0
Jellyfin.sln

@@ -96,6 +96,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Implementations", "src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj", "{8C9F9221-8415-496C-B1F5-E7756F03FA59}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.CodeAnalysis", "src\Jellyfin.CodeAnalysis\Jellyfin.CodeAnalysis.csproj", "{11643D0F-6761-4EF7-AB71-6F9F8DE00714}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -258,6 +260,10 @@ Global
 		{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Release|Any CPU.Build.0 = Release|Any CPU
+		{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -289,6 +295,7 @@ Global
 		{4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
 		{A5590358-33CC-4B39-BDE7-DC62FEB03C76} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
 		{8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
+		{11643D0F-6761-4EF7-AB71-6F9F8DE00714} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
 		SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE}

+ 4 - 2
MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs

@@ -169,7 +169,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
         {
             if (fileInfo.IsExternal)
             {
-                using (var stream = await GetStream(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false))
+                var stream = await GetStream(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false);
+                await using (stream.ConfigureAwait(false))
                 {
                     var result = await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false);
                     var detected = result.Detected;
@@ -937,7 +938,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
                     .ConfigureAwait(false);
             }
 
-            using (var stream = await GetStream(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false))
+            var stream = await GetStream(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false);
+            await using (stream.ConfigureAwait(false))
             {
                 var result = await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false);
                 var charset = result.Detected?.EncodingName ?? string.Empty;

+ 9 - 0
src/Jellyfin.CodeAnalysis/AnalyzerReleases.Shipped.md

@@ -0,0 +1,9 @@
+; Shipped analyzer releases
+; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
+
+## Release 1.0
+
+### New Rules
+Rule ID | Category | Severity | Notes
+--------|----------|----------|-------
+JF0001  | Usage    | Warning  | Async-created IAsyncDisposable objects should use 'await using'

+ 82 - 0
src/Jellyfin.CodeAnalysis/AsyncDisposalPatternAnalyzer.cs

@@ -0,0 +1,82 @@
+using System;
+using System.Collections.Immutable;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Jellyfin.CodeAnalysis;
+
+/// <summary>
+/// Analyzer to detect sync disposal of async-created IAsyncDisposable objects.
+/// </summary>
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public class AsyncDisposalPatternAnalyzer : DiagnosticAnalyzer
+{
+    /// <summary>
+    /// Diagnostic descriptor for sync disposal of async-created IAsyncDisposable objects.
+    /// </summary>
+    public static readonly DiagnosticDescriptor AsyncDisposableSyncDisposal = new(
+        id: "JF0001",
+        title: "Async-created IAsyncDisposable objects should use 'await using'",
+        messageFormat: "Using 'using' with async-created IAsyncDisposable object '{0}'. Use 'await using' instead to prevent resource leaks.",
+        category: "Usage",
+        defaultSeverity: DiagnosticSeverity.Error,
+        isEnabledByDefault: true,
+        description: "Objects that implement IAsyncDisposable and are created using 'await' should be disposed using 'await using' to prevent resource leaks.");
+
+    /// <inheritdoc/>
+    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [AsyncDisposableSyncDisposal];
+
+    /// <inheritdoc/>
+    public override void Initialize(AnalysisContext context)
+    {
+        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+        context.EnableConcurrentExecution();
+        context.RegisterSyntaxNodeAction(AnalyzeUsingStatement, SyntaxKind.UsingStatement);
+    }
+
+    private static void AnalyzeUsingStatement(SyntaxNodeAnalysisContext context)
+    {
+        var usingStatement = (UsingStatementSyntax)context.Node;
+
+        // Skip 'await using' statements
+        if (usingStatement.AwaitKeyword.IsKind(SyntaxKind.AwaitKeyword))
+        {
+            return;
+        }
+
+        // Check if there's a variable declaration
+        if (usingStatement.Declaration?.Variables is null)
+        {
+            return;
+        }
+
+        foreach (var variable in usingStatement.Declaration.Variables)
+        {
+            if (variable.Initializer?.Value is AwaitExpressionSyntax awaitExpression)
+            {
+                var typeInfo = context.SemanticModel.GetTypeInfo(awaitExpression);
+                var type = typeInfo.Type;
+
+                if (type is not null && ImplementsIAsyncDisposable(type))
+                {
+                    var diagnostic = Diagnostic.Create(
+                        AsyncDisposableSyncDisposal,
+                        usingStatement.GetLocation(),
+                        type.Name);
+
+                    context.ReportDiagnostic(diagnostic);
+                }
+            }
+        }
+    }
+
+    private static bool ImplementsIAsyncDisposable(ITypeSymbol type)
+    {
+        return type.AllInterfaces.Any(i =>
+            string.Equals(i.Name, "IAsyncDisposable", StringComparison.Ordinal)
+            && string.Equals(i.ContainingNamespace?.ToDisplayString(), "System", StringComparison.Ordinal));
+    }
+}

+ 17 - 0
src/Jellyfin.CodeAnalysis/Jellyfin.CodeAnalysis.csproj

@@ -0,0 +1,17 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>netstandard2.0</TargetFramework>
+    <LangVersion>latest</LangVersion>
+    <IncludeBuildOutput>false</IncludeBuildOutput>
+    <GeneratePackageOnBuild>false</GeneratePackageOnBuild>
+    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
+    <GenerateDocumentationFile>true</GenerateDocumentationFile>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
+    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" PrivateAssets="all" />
+  </ItemGroup>
+
+</Project>