diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f54ba314..6a88e00d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -149,6 +149,11 @@ jobs: if echo "$out" | grep -q "CleanReportViewModel"; then echo "FAIL: disposed field wrongly reported"; exit 1 fi + # a static IDisposable field is a process-lifetime singleton (Dapper's + # DisposedReader.Instance) — never an owned leak, so it stays silent. + if echo "$out" | grep -q "SharedTokenHolder"; then + echo "FAIL: a static singleton IDisposable field was wrongly reported"; exit 1 + fi # WPF004: an ignored `X.Subscribe(...)` result leaks; the captured+ # disposed one stays silent. "ignored" is unique to the WPF004 message. echo "$out" | grep -q "MessengerViewModel.cs" \ @@ -216,7 +221,9 @@ jobs: # dispose-optional (Task), disposed/escaping locals, a `for` loop (still # skipped: `looped`), and a balanced acquire+dispose in a loop (`whileClean`) # must stay silent: - for ok in clean looped esc exemptTask whileClean; do + # released via `await x.DisposeAsync()` (asyncDisposed) and the chained + # `.ConfigureAwait(false)` form (asyncDisposedCfg) -> both must stay silent. + for ok in clean looped esc exemptTask whileClean asyncDisposed asyncDisposedCfg; do if echo "$out" | grep -q "'$ok'"; then echo "FAIL: silent/exempt case '$ok' was reported"; exit 1; fi done echo "OK: flow-sensitive OWN001/002/003 on real C# (path-sensitive, loops via while/foreach, never-vs-every-path wording, dispose-optional exempt, beyond flat)" diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 1ace3f49..269bcaa1 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -265,10 +265,21 @@ static bool LowerFlowStmt(StatementSyntax st, HashSet tracked, List tracked, List nodes) { - // x.Dispose()/x.Close() on a tracked local -> release. + // `await x.DisposeAsync()` is the IAsyncDisposable release — look through the + // await to the inner call so it counts as disposal, not a bare use. + if (expr is AwaitExpressionSyntax awaited) + expr = awaited.Expression; + // ... and through a trailing `.ConfigureAwait(false)` — the library-idiomatic + // `await x.DisposeAsync().ConfigureAwait(false)` awaits the ConfigureAwait call. + if (expr is InvocationExpressionSyntax cfg + && cfg.Expression is MemberAccessExpressionSyntax cfgMa + && cfgMa.Name.Identifier.Text == "ConfigureAwait" + && cfgMa.Expression is InvocationExpressionSyntax inner) + expr = inner; + // x.Dispose()/x.Close()/x.DisposeAsync() on a tracked local -> release. if (expr is InvocationExpressionSyntax inv && inv.Expression is MemberAccessExpressionSyntax ma - && ma.Name.Identifier.Text is "Dispose" or "Close" + && ma.Name.Identifier.Text is "Dispose" or "Close" or "DisposeAsync" && ma.Expression is IdentifierNameSyntax rid && tracked.Contains(rid.Identifier.Text)) { @@ -449,6 +460,11 @@ or ImplicitObjectCreationExpressionSyntax foreach (var fd in cls.Members.OfType()) { + // a `static` IDisposable field is a process-lifetime singleton (a shared + // HttpClient, a sentinel like Dapper's DisposedReader.Instance) — it is + // intentionally never disposed, so it is not an owned leak. + if (fd.Modifiers.Any(m => m.IsKind(SyntaxKind.StaticKeyword))) + continue; var tname = fd.Declaration.Type.ToString(); if (!IsDisposableType(tname)) continue; diff --git a/frontend/roslyn/samples/DisposableFieldViewModel.cs b/frontend/roslyn/samples/DisposableFieldViewModel.cs index f7948501..101d4360 100644 --- a/frontend/roslyn/samples/DisposableFieldViewModel.cs +++ b/frontend/roslyn/samples/DisposableFieldViewModel.cs @@ -30,3 +30,11 @@ public void Dispose() _cts.Dispose(); // release } } + +// A `static` IDisposable field has process lifetime — it is intentionally never +// disposed (a shared HttpClient, or a sentinel like Dapper's DisposedReader.Instance, +// a false positive found by mining). The detector must NOT flag it -> silent. +public sealed class SharedTokenHolder +{ + public static readonly CancellationTokenSource Shared = new(); +} diff --git a/frontend/roslyn/samples/FlowLocalsSample.cs b/frontend/roslyn/samples/FlowLocalsSample.cs index 3e7bbace..1eea07c4 100644 --- a/frontend/roslyn/samples/FlowLocalsSample.cs +++ b/frontend/roslyn/samples/FlowLocalsSample.cs @@ -113,4 +113,24 @@ public void TimerLeaks() var realTimer = new Timer(_ => { }); realTimer.Change(0, 1000); } + + // `await x.DisposeAsync()` is the IAsyncDisposable release and must count as + // disposal, not a leak. Reduced from Dapper's WrappedReaderTests + // (DbWrappedReader_DisposeAsync_DoesNotThrow), a false positive found by mining. + // Silent. + public async Task DisposedAsync() + { + var asyncDisposed = new MemoryStream(); + asyncDisposed.WriteByte(1); + await asyncDisposed.DisposeAsync(); + } + + // the library-idiomatic chained form `await x.DisposeAsync().ConfigureAwait(false)` + // is also disposal, not a leak (CodeRabbit caught this gap). Silent. + public async Task DisposedAsyncConfigured() + { + var asyncDisposedCfg = new MemoryStream(); + asyncDisposedCfg.WriteByte(1); + await asyncDisposedCfg.DisposeAsync().ConfigureAwait(false); + } }