From 106659a06e8dcd2aee9db1c7a59c88d381cf6f84 Mon Sep 17 00:00:00 2001 From: George Wall Date: Mon, 7 Sep 2026 18:27:11 +0100 Subject: [PATCH 1/4] feat: share HCR060 escape gate and withhold fix for argument transfers Extract VariableEscapesScope into internal UsingDeclarationEscapeGate so HCR081 can reuse it, and extend the escape definition to values passed as arguments (including parenthesized). Direct unit tests cover every gate branch; behavior test covers the argument case. --- .../HCR060_DisposeResponseCodeFixProvider.cs | 35 +---- .../CodeFixes/UsingDeclarationEscapeGate.cs | 71 ++++++++++ .../UsingDeclarationEscapeGateTests.cs | 133 ++++++++++++++++++ ...esponseHeadersReadDisposalAnalyzerTests.cs | 28 ++++ 4 files changed, 235 insertions(+), 32 deletions(-) create mode 100644 src/HttpClient.Resilience.Analyzers/CodeFixes/UsingDeclarationEscapeGate.cs create mode 100644 tests/HttpClient.Resilience.Analyzers.Tests/CodeFixes/UsingDeclarationEscapeGateTests.cs diff --git a/src/HttpClient.Resilience.Analyzers/CodeFixes/HCR060_DisposeResponseCodeFixProvider.cs b/src/HttpClient.Resilience.Analyzers/CodeFixes/HCR060_DisposeResponseCodeFixProvider.cs index 157e42d..b6d6fb5 100644 --- a/src/HttpClient.Resilience.Analyzers/CodeFixes/HCR060_DisposeResponseCodeFixProvider.cs +++ b/src/HttpClient.Resilience.Analyzers/CodeFixes/HCR060_DisposeResponseCodeFixProvider.cs @@ -1,6 +1,5 @@ using System.Collections.Immutable; using System.Composition; -using System.Linq; using System.Threading; using System.Threading.Tasks; using HttpClient.Resilience.Analyzers.Diagnostics; @@ -43,7 +42,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) declaration.Declaration.Variables.Count == 1) { var variableName = declaration.Declaration.Variables[0].Identifier.ValueText; - if (!VariableEscapesScope(node, variableName)) + if (!UsingDeclarationEscapeGate.VariableEscapesScope(node, variableName)) { context.RegisterCodeFix( CodeAction.Create( @@ -64,7 +63,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) out var assignmentStatement)) { var mergedName = adjacentDeclaration.Declaration.Variables[0].Identifier.ValueText; - if (!VariableEscapesScope(node, mergedName)) + if (!UsingDeclarationEscapeGate.VariableEscapesScope(node, mergedName)) { context.RegisterCodeFix( CodeAction.Create( @@ -91,7 +90,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) out var topLevelAssignmentStatement)) { var topLevelName = topLevelDeclaration.Declaration.Variables[0].Identifier.ValueText; - if (!VariableEscapesScope(node, topLevelName)) + if (!UsingDeclarationEscapeGate.VariableEscapesScope(node, topLevelName)) { context.RegisterCodeFix( CodeAction.Create( @@ -155,34 +154,6 @@ variables[0].Initializer is not null || assignmentStatement = statement; return true; } - private static bool VariableEscapesScope(SyntaxNode node, string variableName) - { - if (string.IsNullOrEmpty(variableName)) - { - return false; - } - - SyntaxNode? scope = node.FirstAncestorOrSelf(); - scope ??= node.FirstAncestorOrSelf(); - if (scope is null) - { - return false; - } - - // Disposing at scope end breaks callers when the response outlives the block: - // returned directly or stored into a member or another container. - return scope.DescendantNodes() - .Any(descendant => descendant switch - { - ReturnStatementSyntax { Expression: IdentifierNameSyntax returned } => - returned.Identifier.ValueText == variableName, - AssignmentExpressionSyntax assignment when assignment.Left is not IdentifierNameSyntax => - assignment.Right.DescendantNodesAndSelf() - .OfType() - .Any(identifier => identifier.Identifier.ValueText == variableName), - _ => false, - }); - } private static async Task AddUsingDeclarationAsync( Document document, diff --git a/src/HttpClient.Resilience.Analyzers/CodeFixes/UsingDeclarationEscapeGate.cs b/src/HttpClient.Resilience.Analyzers/CodeFixes/UsingDeclarationEscapeGate.cs new file mode 100644 index 0000000..05df352 --- /dev/null +++ b/src/HttpClient.Resilience.Analyzers/CodeFixes/UsingDeclarationEscapeGate.cs @@ -0,0 +1,71 @@ +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace HttpClient.Resilience.Analyzers.CodeFixes; + +/// +/// Decides whether a `using` declaration fix is safe for a disposable local. +/// Disposing at scope end breaks callers when the value outlives the block, +/// so no automatic fix is offered once the variable escapes. +/// +internal static class UsingDeclarationEscapeGate +{ + internal static bool VariableEscapesScope(SyntaxNode node, string variableName) + { + if (string.IsNullOrEmpty(variableName)) + { + return false; + } + + SyntaxNode? scope = node.FirstAncestorOrSelf(); + scope ??= node.FirstAncestorOrSelf(); + if (scope is null) + { + return false; + } + + return scope.DescendantNodes() + .OfType() + .Where(identifier => identifier.Identifier.ValueText == variableName) + .Any(IsTransferredOut); + } + + private static bool IsTransferredOut(IdentifierNameSyntax identifier) + { + var child = (SyntaxNode)identifier; + for (var current = identifier.Parent; current is not null; child = current, current = current.Parent) + { + // Merely calling a member on the value keeps ownership local. + if (current is MemberAccessExpressionSyntax memberAccess && + ReferenceEquals(memberAccess.Expression, child)) + { + return false; + } + + if (current is ArgumentSyntax) + { + return true; + } + + if (current is ReturnStatementSyntax) + { + return true; + } + + if (current is AssignmentExpressionSyntax assignment) + { + // `variable = ...` keeps ownership local; any other target + // (member, element, or container) leaks it. + return assignment.Left is not IdentifierNameSyntax; + } + + if (current is not ParenthesizedExpressionSyntax and not EqualsValueClauseSyntax) + { + return false; + } + } + + return false; + } +} diff --git a/tests/HttpClient.Resilience.Analyzers.Tests/CodeFixes/UsingDeclarationEscapeGateTests.cs b/tests/HttpClient.Resilience.Analyzers.Tests/CodeFixes/UsingDeclarationEscapeGateTests.cs new file mode 100644 index 0000000..6c54303 --- /dev/null +++ b/tests/HttpClient.Resilience.Analyzers.Tests/CodeFixes/UsingDeclarationEscapeGateTests.cs @@ -0,0 +1,133 @@ +using System.Linq; +using System.Threading.Tasks; +using HttpClient.Resilience.Analyzers.CodeFixes; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Xunit; + +namespace HttpClient.Resilience.Analyzers.Tests.CodeFixes; + +public sealed class UsingDeclarationEscapeGateTests +{ + [Fact] + public async Task EmptyName_NeverEscapes() + { + var declaration = ParseResponseDeclaration("return Task.FromResult(0);"); + + Assert.False(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, string.Empty)); + await Task.CompletedTask; + } + + [Fact] + public async Task DetachedNode_NeverEscapes() + { + var detached = SyntaxFactory.IdentifierName("response"); + + Assert.False(UsingDeclarationEscapeGate.VariableEscapesScope(detached, "response")); + await Task.CompletedTask; + } + + [Fact] + public async Task ReturnedIdentifier_Escapes() + { + var declaration = ParseResponseDeclaration("return response;"); + + Assert.True(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response")); + await Task.CompletedTask; + } + + [Fact] + public async Task ReturnedTuple_Escapes() + { + var declaration = ParseResponseDeclaration("return (response, 1);"); + + Assert.True(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response")); + await Task.CompletedTask; + } + + [Fact] + public async Task StoredIntoMember_Escapes() + { + var declaration = ParseResponseDeclaration("this.pending = response;"); + + Assert.True(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response")); + await Task.CompletedTask; + } + + [Fact] + public async Task PassedAsArgument_Escapes() + { + var declaration = ParseResponseDeclaration("Takes(response);"); + + Assert.True(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response")); + await Task.CompletedTask; + } + + [Fact] + public async Task ParenthesizedArgument_Escapes() + { + var declaration = ParseResponseDeclaration("Takes((response));"); + + Assert.True(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response")); + await Task.CompletedTask; + } + + [Fact] + public async Task MemberCall_DoesNotEscape() + { + var declaration = ParseResponseDeclaration("_ = response.Content.ReadAsStringAsync();"); + + Assert.False(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response")); + await Task.CompletedTask; + } + + [Fact] + public async Task MemberAccessAsArgument_DoesNotEscape() + { + var declaration = ParseResponseDeclaration("Takes(response.Content);"); + + Assert.False(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response")); + await Task.CompletedTask; + } + + [Fact] + public async Task LocalReassignment_DoesNotEscape() + { + var declaration = ParseResponseDeclaration("response = other;"); + + Assert.False(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response")); + await Task.CompletedTask; + } + + [Fact] + public async Task TypeTest_DoesNotEscape() + { + var declaration = ParseResponseDeclaration("if (response is not null) { Takes(1); }"); + + Assert.False(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response")); + await Task.CompletedTask; + } + + private static LocalDeclarationStatementSyntax ParseResponseDeclaration(string tailStatement) + { + var root = CSharpSyntaxTree.ParseText( + """ + using System.Net.Http; + using System.Threading.Tasks; + + public sealed class Client + { + private HttpResponseMessage? pending; + + public async Task UseAsync(HttpClient client) + { + var response = await client.GetAsync("https://example.com"); + """ + + "\n " + tailStatement + "\n }\n }\n").GetCompilationUnitRoot(); + + return root.DescendantNodes() + .OfType() + .First(declaration => declaration.Declaration.Variables.Any(variable => variable.Identifier.ValueText == "response")); + } +} diff --git a/tests/HttpClient.Resilience.Analyzers.Tests/ResponseLifetime/HCR060_ResponseHeadersReadDisposalAnalyzerTests.cs b/tests/HttpClient.Resilience.Analyzers.Tests/ResponseLifetime/HCR060_ResponseHeadersReadDisposalAnalyzerTests.cs index 29309f1..328156e 100644 --- a/tests/HttpClient.Resilience.Analyzers.Tests/ResponseLifetime/HCR060_ResponseHeadersReadDisposalAnalyzerTests.cs +++ b/tests/HttpClient.Resilience.Analyzers.Tests/ResponseLifetime/HCR060_ResponseHeadersReadDisposalAnalyzerTests.cs @@ -1460,6 +1460,34 @@ public async Task UseAsync(HttpClient client, HttpRequestMessage request, Cancel Assert.Empty(titles); } + [Fact] + public async Task CodeFix_IsNotOffered_WhenResponseIsPassedAsArgument() + { + const string source = """ + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + + public sealed class Client + { + public async Task UseAsync(HttpClient client, HttpRequestMessage request, CancellationToken cancellationToken) + { + var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + _ = await response.Content.ReadAsStringAsync(cancellationToken); + Takes(response); + } + + private static void Takes(HttpResponseMessage message) + { + } + } + """; + + var titles = await CodeFixVerifier + .GetCodeFixTitlesAsync(source); + + Assert.Empty(titles); + } [Fact] public async Task DoesNotReport_WhenResponseIsReturned() From 4a8c1a0db59da83ad8ee28f9d55028eb7351b665 Mon Sep 17 00:00:00 2001 From: George Wall Date: Mon, 7 Sep 2026 18:41:23 +0100 Subject: [PATCH 2/4] fix: treat object and collection initializers as escape targets --- .../CodeFixes/UsingDeclarationEscapeGate.cs | 14 +++++++++ .../UsingDeclarationEscapeGateTests.cs | 17 +++++++++++ ...esponseHeadersReadDisposalAnalyzerTests.cs | 30 +++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/src/HttpClient.Resilience.Analyzers/CodeFixes/UsingDeclarationEscapeGate.cs b/src/HttpClient.Resilience.Analyzers/CodeFixes/UsingDeclarationEscapeGate.cs index 05df352..72f3454 100644 --- a/src/HttpClient.Resilience.Analyzers/CodeFixes/UsingDeclarationEscapeGate.cs +++ b/src/HttpClient.Resilience.Analyzers/CodeFixes/UsingDeclarationEscapeGate.cs @@ -53,8 +53,22 @@ private static bool IsTransferredOut(IdentifierNameSyntax identifier) return true; } + // Object, collection, array, and `with` initializers all store into + // the new aggregate (WithInitializerExpression shares this node type). + if (current is InitializerExpressionSyntax) + { + return true; + } + if (current is AssignmentExpressionSyntax assignment) { + // Initializer members (`new Foo { Bar = value }`) store into the + // new aggregate even though the member reads as an identifier. + if (assignment.Parent is InitializerExpressionSyntax) + { + return true; + } + // `variable = ...` keeps ownership local; any other target // (member, element, or container) leaks it. return assignment.Left is not IdentifierNameSyntax; diff --git a/tests/HttpClient.Resilience.Analyzers.Tests/CodeFixes/UsingDeclarationEscapeGateTests.cs b/tests/HttpClient.Resilience.Analyzers.Tests/CodeFixes/UsingDeclarationEscapeGateTests.cs index 6c54303..f337bb7 100644 --- a/tests/HttpClient.Resilience.Analyzers.Tests/CodeFixes/UsingDeclarationEscapeGateTests.cs +++ b/tests/HttpClient.Resilience.Analyzers.Tests/CodeFixes/UsingDeclarationEscapeGateTests.cs @@ -72,6 +72,23 @@ public async Task ParenthesizedArgument_Escapes() Assert.True(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response")); await Task.CompletedTask; } + [Fact] + public async Task ObjectInitializerMember_Escapes() + { + var declaration = ParseResponseDeclaration("var holder = new Holder { Value = response };"); + + Assert.True(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response")); + await Task.CompletedTask; + } + + [Fact] + public async Task CollectionInitializerElement_Escapes() + { + var declaration = ParseResponseDeclaration("var items = new System.Collections.Generic.List { response };"); + + Assert.True(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response")); + await Task.CompletedTask; + } [Fact] public async Task MemberCall_DoesNotEscape() diff --git a/tests/HttpClient.Resilience.Analyzers.Tests/ResponseLifetime/HCR060_ResponseHeadersReadDisposalAnalyzerTests.cs b/tests/HttpClient.Resilience.Analyzers.Tests/ResponseLifetime/HCR060_ResponseHeadersReadDisposalAnalyzerTests.cs index 328156e..d72260a 100644 --- a/tests/HttpClient.Resilience.Analyzers.Tests/ResponseLifetime/HCR060_ResponseHeadersReadDisposalAnalyzerTests.cs +++ b/tests/HttpClient.Resilience.Analyzers.Tests/ResponseLifetime/HCR060_ResponseHeadersReadDisposalAnalyzerTests.cs @@ -1488,6 +1488,36 @@ private static void Takes(HttpResponseMessage message) Assert.Empty(titles); } + [Fact] + public async Task CodeFix_IsNotOffered_WhenResponseIsStoredIntoInitializer() + { + const string source = """ + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + + public sealed class Holder + { + public HttpResponseMessage? Value { get; set; } + } + + public sealed class Client + { + public async Task UseAsync(HttpClient client, HttpRequestMessage request, CancellationToken cancellationToken) + { + var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + _ = await response.Content.ReadAsStringAsync(cancellationToken); + var holder = new Holder { Value = response }; + _ = holder; + } + } + """; + + var titles = await CodeFixVerifier + .GetCodeFixTitlesAsync(source); + + Assert.Empty(titles); + } [Fact] public async Task DoesNotReport_WhenResponseIsReturned() From c2ce7c7c4cccce2971a71d99aa88475fa4266bfa Mon Sep 17 00:00:00 2001 From: George Wall Date: Mon, 7 Sep 2026 18:48:23 +0100 Subject: [PATCH 3/4] test: cover split-declaration and top-level escape paths --- ...esponseHeadersReadDisposalAnalyzerTests.cs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/HttpClient.Resilience.Analyzers.Tests/ResponseLifetime/HCR060_ResponseHeadersReadDisposalAnalyzerTests.cs b/tests/HttpClient.Resilience.Analyzers.Tests/ResponseLifetime/HCR060_ResponseHeadersReadDisposalAnalyzerTests.cs index d72260a..c577483 100644 --- a/tests/HttpClient.Resilience.Analyzers.Tests/ResponseLifetime/HCR060_ResponseHeadersReadDisposalAnalyzerTests.cs +++ b/tests/HttpClient.Resilience.Analyzers.Tests/ResponseLifetime/HCR060_ResponseHeadersReadDisposalAnalyzerTests.cs @@ -1518,6 +1518,55 @@ public async Task UseAsync(HttpClient client, HttpRequestMessage request, Cancel Assert.Empty(titles); } + [Fact] + public async Task CodeFix_IsNotOffered_WhenSplitDeclarationResponseIsStoredIntoMember() + { + const string source = """ + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + + public sealed class Client + { + private HttpResponseMessage? pending; + + public async Task UseAsync(HttpClient client, HttpRequestMessage request, CancellationToken cancellationToken) + { + HttpResponseMessage response; + response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + _ = await response.Content.ReadAsStringAsync(cancellationToken); + this.pending = response; + } + } + """; + + var titles = await CodeFixVerifier + .GetCodeFixTitlesAsync(source); + + Assert.Empty(titles); + } + + [Fact] + public async Task CodeFix_IsNotOffered_WhenTopLevelResponseIsPassedAsArgument() + { + const string source = """ + using System.Net.Http; + + HttpClient client = new(); + HttpResponseMessage response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://example.com"), HttpCompletionOption.ResponseHeadersRead); + _ = await response.Content.ReadAsStringAsync(); + Takes(response); + + void Takes(HttpResponseMessage message) + { + } + """; + + var titles = await CodeFixVerifier + .GetCodeFixTitlesAsync(source); + + Assert.Empty(titles); + } [Fact] public async Task DoesNotReport_WhenResponseIsReturned() From e6c9ae7ddc9eb6062fabf69ad4fad729edadd54c Mon Sep 17 00:00:00 2001 From: George Wall Date: Mon, 7 Sep 2026 18:50:54 +0100 Subject: [PATCH 4/4] style: match house Block-or-CompilationUnit scope idiom --- .../CodeFixes/UsingDeclarationEscapeGate.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/HttpClient.Resilience.Analyzers/CodeFixes/UsingDeclarationEscapeGate.cs b/src/HttpClient.Resilience.Analyzers/CodeFixes/UsingDeclarationEscapeGate.cs index 72f3454..569dafe 100644 --- a/src/HttpClient.Resilience.Analyzers/CodeFixes/UsingDeclarationEscapeGate.cs +++ b/src/HttpClient.Resilience.Analyzers/CodeFixes/UsingDeclarationEscapeGate.cs @@ -18,8 +18,8 @@ internal static bool VariableEscapesScope(SyntaxNode node, string variableName) return false; } - SyntaxNode? scope = node.FirstAncestorOrSelf(); - scope ??= node.FirstAncestorOrSelf(); + SyntaxNode? scope = node.FirstAncestorOrSelf() as SyntaxNode ?? + node.FirstAncestorOrSelf(); if (scope is null) { return false;