- Notifications
You must be signed in to change notification settings - Fork 0
fix(extractor): treat closure-captured locals as escaped (ShareX throttler FP)#59
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| # Closure-capture escape — a local captured by a lambda is not method-bounded | ||
| A precision fix driven by triaging the **re-mine of ShareX** (after the WinForms | ||
| modeless-`Form` fix, #57): of the seven local-disposable findings that survived, six | ||
| were real or defensible and **one was a false positive** — `Helpers.ForEachAsync`'s | ||
| `SemaphoreSlim throttler`: | ||
| ```csharp | ||
| public static Task ForEachAsync<T>(IEnumerable<T> items, Func<T, Task> body, int max) | ||
| { | ||
| SemaphoreSlim throttler = new SemaphoreSlim(max, max); | ||
| IEnumerable<Task> tasks = items.Select(async input => | ||
| { | ||
| await throttler.WaitAsync(); // throttler used INSIDE the lambda | ||
| try { await body(input); } finally { throttler.Release(); } | ||
| }); | ||
| return Task.WhenAll(tasks); // the lambdas (and throttler) escape | ||
| } | ||
| ``` | ||
| The flow detector saw `throttler = new SemaphoreSlim(...)` (an undisposed `IDisposable` | ||
| local) and flagged OWN001. But `throttler` is **captured by the async lambdas**, and | ||
| those lambdas escape the method — they run while the returned `Task.WhenAll(tasks)` is | ||
| awaited by the caller. The semaphore must stay alive until every task finishes, so it | ||
| *cannot* be disposed at method scope. It is not a method-local leak. | ||
| ## The fix — capture into a closure is an escape | ||
| The flow detector already untracks a local that escapes by **return**, **out/ref**, or | ||
| being **passed as an argument** (an ambiguous ownership transfer). A capture into a | ||
| closure is the same kind of escape — the closure can be stored, returned, or run async, | ||
| so the local outlives the method frame. The escape filter now also untracks a candidate | ||
| local when any reference to it is **lexically inside a lambda / anonymous method / local | ||
| function** body: | ||
| ```csharp | ||
| // in the --flow-locals escape filter, before the return/out/arg checks: | ||
| var capturedInClosure = false; | ||
| for (var a = idn.Parent; a is not null && a != mbody; a = a.Parent) | ||
| if (a is AnonymousFunctionExpressionSyntax or LocalFunctionStatementSyntax) | ||
| { capturedInClosure = true; break; } | ||
| if (capturedInClosure) { escapedLocals.Add(nm); continue; } | ||
| ``` | ||
| It is purely syntactic (an ancestor walk to the method body), so it is crash-proof and | ||
| needs no escape/data-flow analysis of where the closure goes. It deliberately does **not** | ||
| require proving the closure escapes: a captured local *may* outlive the method, and the | ||
| precision-first stance is to not flag what we cannot prove leaks. | ||
| One syntactic exception: a **`nameof(x)`** operand is skipped before the escape checks. It | ||
| exposes an `IdentifierNameSyntax` under the closure (or as an argument), but `nameof` is a | ||
| compile-time string that captures and transfers nothing — so it must not be mistaken for a | ||
| capture (nor, on the argument path, an ownership transfer), or a still-leaking method-bounded | ||
| local would be wrongly untracked (Codex review). Pinned by the `NameofInLambda` sample case. | ||
| ### Why not just exempt `SemaphoreSlim`? | ||
| `SemaphoreSlim` is **not** unconditionally dispose-optional: accessing | ||
| `AvailableWaitHandle` lazily allocates a wait handle that `Dispose()` must release (CA2000 | ||
| flags an undisposed one). Blanket-exempting it (the way `Task`/`DataTable` are exempt in | ||
| `IsDisposeOptional`) would be unsound — it would hide a real method-local semaphore leak. | ||
| The bug here is the **capture/escape**, not the type, so the fix targets the capture. | ||
| ## The recall trade-off (sound, bounded) | ||
| The rule is conservative: a local captured by a closure that does **not** escape, and is | ||
| never disposed, is now silenced too (e.g. `var s = new MemoryStream(); Action a = () => | ||
| s.Use(); a(); /* never disposed */`). Proving such a closure stays method-local is exactly | ||
| the data-flow analysis the syntactic rule avoids, so this is an accepted recall gap, never | ||
| a false positive — the same precision-over-recall trade the escape filter already makes for | ||
| argument-passing. | ||
| ## Pinned in CI | ||
| `frontend/roslyn/samples/FlowLocalsSample.cs` gains two cases, asserted in the | ||
| `wpf-extractor` `--flow-locals` step: | ||
| - `ThrottlerCaptured` — a `SemaphoreSlim` captured by a returned async lambda → **silent** | ||
| (the FP this removes); | ||
| - `SemaphoreLeaks` — a `SemaphoreSlim` **not** captured by any closure and never disposed → | ||
| **OWN001**, proving the exemption is closure-capture, not a blanket `SemaphoreSlim` | ||
| dispose-optional. | ||
| The existing `UnitOfWorkFlowSample` OWN001 is unaffected: its `uow` is always the | ||
| *receiver* of `uow.Member` (outside the `.Where(p => …)` lambda bodies, which reference | ||
| `p`), and the `join … in uow.TempProducts` is query syntax, not an | ||
| `AnonymousFunctionExpressionSyntax` — so the ancestor walk never marks `uow` captured. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -11,7 +11,7 @@ | ||
| // OWN002: used after Dispose() | ||
| public void UseAfterDispose() | ||
| { | ||
| var uad = new MemoryStream(); | ||
Check failure on line 14 in frontend/roslyn/samples/FlowLocalsSample.cs
| ||
| uad.WriteByte(1); | ||
| uad.Dispose(); | ||
| uad.WriteByte(2); | ||
| @@ -20,7 +20,7 @@ | ||
| // OWN001: disposed only on the `then` path -> leaks on the else path | ||
| public void LeakOnElse(bool c) | ||
| { | ||
| var leak = new MemoryStream(); | ||
Check warning on line 23 in frontend/roslyn/samples/FlowLocalsSample.cs
| ||
| if (c) | ||
| { | ||
| leak.Dispose(); | ||
| @@ -30,7 +30,7 @@ | ||
| // OWN003: disposed twice | ||
| public void DoubleDispose() | ||
| { | ||
| var dbl = new MemoryStream(); | ||
Check failure on line 33 in frontend/roslyn/samples/FlowLocalsSample.cs
| ||
| dbl.Dispose(); | ||
| dbl.Dispose(); | ||
| } | ||
| @@ -60,7 +60,7 @@ | ||
| { | ||
| while (n > 0) | ||
| { | ||
| var whileLeak = new MemoryStream(); | ||
Check failure on line 63 in frontend/roslyn/samples/FlowLocalsSample.cs
| ||
| whileLeak.WriteByte(1); | ||
| n = n - 1; | ||
| } | ||
| @@ -71,7 +71,7 @@ | ||
| { | ||
| foreach (var it in items) | ||
| { | ||
| var foreachLeak = new MemoryStream(); | ||
Check failure on line 74 in frontend/roslyn/samples/FlowLocalsSample.cs
| ||
| foreachLeak.WriteByte((byte)it); | ||
| } | ||
| } | ||
| @@ -83,7 +83,7 @@ | ||
| { | ||
| for (int i = 0; i < n; i++) | ||
| { | ||
| var forLeak = new MemoryStream(); | ||
Check failure on line 86 in frontend/roslyn/samples/FlowLocalsSample.cs
| ||
| forLeak.WriteByte((byte)i); | ||
| } | ||
| } | ||
| @@ -403,6 +403,44 @@ | ||
| asyncDisposedCfg.WriteByte(1); | ||
| await asyncDisposedCfg.DisposeAsync().ConfigureAwait(false); | ||
| } | ||
| // NOT a leak (precision): a SemaphoreSlim captured into an async lambda whose Task is | ||
| // returned outlives the method — the caller awaits it, so it cannot be disposed at | ||
| // method scope. A local referenced inside a closure (lambda / anonymous method / local | ||
| // function) is treated as escaped, exactly like a returned/out-passed local. Reduced | ||
| // from a ShareX false positive (Helpers.ForEachAsync's `throttler` captured by the async | ||
| // lambdas of a returned Task.WhenAll). Silent. | ||
| public Task ThrottlerCaptured(int max) | ||
| { | ||
| var captured = new SemaphoreSlim(max, max); | ||
| Func<Task> run = async () => | ||
| { | ||
| await captured.WaitAsync(); | ||
| captured.Release(); | ||
| }; | ||
| return run(); | ||
| } | ||
| // control (must still leak): a SemaphoreSlim NOT captured by any closure and never | ||
| // disposed -> OWN001. Proves the exemption is about CLOSURE CAPTURE (escape), not | ||
| // SemaphoreSlim being blanket dispose-optional — it is not (accessing AvailableWaitHandle | ||
| // allocates a handle Dispose must release), so it must stay tracked when method-bounded. | ||
| public void SemaphoreLeaks() | ||
| { | ||
| var semLeak = new SemaphoreSlim(1, 1); | ||
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Uh oh!There was an error while loading. Please reload this page. | ||
| semLeak.Wait(); | ||
| } | ||
| // OWN001 (Codex review on #59): a `nameof(x)` operand inside a lambda exposes an | ||
| // identifier under the closure, but `nameof` is a compile-time string — it captures | ||
| // nothing. `nofLeak` is only mentioned via nameof, so it is still method-bounded and | ||
| // never disposed -> a real leak. The nameof operand must not be mistaken for a capture. | ||
| public void NameofInLambda() | ||
| { | ||
| var nofLeak = new MemoryStream(); | ||
| Action log = () => System.Console.WriteLine(nameof(nofLeak)); | ||
| log(); | ||
| } | ||
| } | ||
| // A domain exception type literally named `Exception`, in a non-System namespace — the | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.