Skip to content

Commit 3b6dd4b

Browse files
authored
Merge pull request #75 from PhysShell/claude/wpf-recall-misses
feat(wpf): field-mediated cross-method use-after-dispose → OWN002
2 parents d646d8d + 93ae291 commit 3b6dd4b

7 files changed

Lines changed: 323 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -796,7 +796,11 @@ jobs:
796796
# leak / double-dispose ride the flow (POOL001/003 — `memorypool-double-dispose` -> OWN003), and
797797
# its `owner.Memory` / `owner.Memory.Span` view is a borrow lowered to a use of the OWNER
798798
# (`ViewOwner`), so reading it after Dispose trips OWN002 (POOL002 — `memorypool-view-after-
799-
# dispose`). Remaining backlog: a FIELD-mediated cross-method use-after-dispose, a view stored in
800-
# a FIELD, and an injected-source region-escape. A drop below the floor is a regression.
801-
run: python scripts/benchmark.py --min-recall 19
799+
# dispose`). A FIELD-mediated cross-method use-after-dispose is caught too: an IDisposable field
800+
# disposed in `Dispose()` and DIRECTLY read (`_field.Member`) in a live subscription-target handler
801+
# (RHS of a `+=` / arg of a `.Subscribe(...)`, not torn down, no `if (_disposed) return;` guard) is
802+
# lowered to a synthetic acquire/release/use flow -> OWN002 (`field-use-after-dispose`). Remaining
803+
# backlog: a view stored in a FIELD, an INDIRECT (helper-mediated) field use after dispose, and an
804+
# injected-source region-escape. A drop below the floor is a regression.
805+
run: python scripts/benchmark.py --min-recall 20
802806

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// FIXED. The handler guards on the disposed flag, so a late dispatcher callback
2+
// bails before touching the connection. (Equivalently, drain the dispatcher queue
3+
// before disposing.) Nothing reads the connection after Dispose(), so the
4+
// extractor's field-mediated use-after-dispose detector — which excludes a handler
5+
// that opens with a `if (_disposed) return;` guard — stays silent.
6+
using System;
7+
using System.Data.SqlClient;
8+
9+
public sealed class ReportViewModel : IDisposable
10+
{
11+
private readonly SqlConnection _conn;
12+
private readonly IDisposable _sub;
13+
private bool _disposed;
14+
15+
public ReportViewModel(IEventBus bus)
16+
{
17+
_conn = new SqlConnection("Server=.;Database=Reports");
18+
_sub = bus.Subscribe(OnDataChanged);
19+
}
20+
21+
private void OnDataChanged(DataChanged e)
22+
{
23+
if (_disposed) return; // do not touch disposed state
24+
_conn.ChangeDatabase(e.Database);
25+
}
26+
27+
public void Dispose()
28+
{
29+
_disposed = true;
30+
_sub.Dispose();
31+
_conn.Dispose();
32+
}
33+
}
34+
35+
// Minimal in-file stand-ins so the reduction is self-contained.
36+
public interface IEventBus
37+
{
38+
IDisposable Subscribe(Action<DataChanged> handler);
39+
}
40+
41+
public sealed class DataChanged
42+
{
43+
public string Database { get; set; } = "";
44+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// BUGGY (representative WPF/MVVM pattern; the C# extractor now catches this
2+
// directly under --flow-locals).
3+
//
4+
// A ViewModel OWNS a SqlConnection field and subscribes a handler to an injected
5+
// event bus. On teardown Dispose() disposes the connection (and the subscription
6+
// token), but a callback that was ALREADY queued on the dispatcher can still run
7+
// after Dispose() and DIRECTLY touch the disposed connection
8+
// (`_conn.ChangeDatabase(...)` on a connection already returned to the pool): an
9+
// ObjectDisposedException / use-after-dispose.
10+
//
11+
// Unlike handler-use-after-dispose — whose handler reaches the disposed state
12+
// INDIRECTLY through a `Refresh()` helper, which the extractor cannot follow — this
13+
// handler reads the disposed FIELD directly, so the extractor's field-mediated
14+
// cross-method detector lowers it to a synthetic acquire/release/use flow and the
15+
// core reports OWN002. The fix (after.cs) guards the handler on the disposed flag.
16+
using System;
17+
using System.Data.SqlClient;
18+
19+
public sealed class ReportViewModel : IDisposable
20+
{
21+
private readonly SqlConnection _conn;
22+
private readonly IDisposable _sub;
23+
24+
public ReportViewModel(IEventBus bus)
25+
{
26+
_conn = new SqlConnection("Server=.;Database=Reports");
27+
_sub = bus.Subscribe(OnDataChanged); // token captured + disposed below
28+
}
29+
30+
private void OnDataChanged(DataChanged e)
31+
{
32+
// a late, already-dispatched callback: may run AFTER Dispose()
33+
_conn.ChangeDatabase(e.Database); // <-- BUG: _conn may already be disposed
34+
}
35+
36+
public void Dispose()
37+
{
38+
_sub.Dispose(); // unsubscribe
39+
_conn.Dispose(); // dispose the owned connection
40+
}
41+
}
42+
43+
// Minimal in-file stand-ins so the reduction is self-contained.
44+
public interface IEventBus
45+
{
46+
IDisposable Subscribe(Action<DataChanged> handler);
47+
}
48+
49+
public sealed class DataChanged
50+
{
51+
public string Database { get; set; } = "";
52+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// OwnLang model of the field-mediated cross-method use-after-dispose the C#
2+
// extractor now lowers DIRECTLY (a disposed IDisposable field read in a subscribed
3+
// handler). `acquire` == the owned connection field's construction, `release` ==
4+
// its `Dispose()` in the ViewModel's Dispose(), `use` == a late dispatcher callback
5+
// (the subscribed handler) reading the connection AFTER it was disposed. Using a
6+
// disposable after its release is the generic OWN002, tagged with the resource kind.
7+
//
8+
// Contrast handler-use-after-dispose, whose handler reaches the disposed state
9+
// INDIRECTLY through a helper (`Refresh()`): the extractor only catches the DIRECT
10+
// `_field.Member` read, so that sibling case stays an honest extractor miss while
11+
// this one is caught end-to-end.
12+
module WpfFieldUseAfterDispose
13+
14+
// The owned IDisposable field (a SqlConnection): acquired when the ViewModel
15+
// constructs it, released by Dispose(). `kind` tags the verdict as a disposable.
16+
resource Connection {
17+
acquire Open
18+
release Dispose
19+
kind "disposable"
20+
}
21+
22+
fn OnDataChanged(bus: int) {
23+
let conn = acquire Connection(bus);
24+
release conn; // ViewModel.Dispose() disposes the owned connection
25+
use conn; // a late queued callback still touches it -> OWN002
26+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
OWN002
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# WPF field-mediated use-after-dispose (a disposed field touched in a handler)
2+
3+
**Pattern:** a ViewModel owns an `IDisposable` field (here a `SqlConnection`) and
4+
subscribes a handler to an event source. On teardown `Dispose()` disposes the field
5+
(and the subscription token), but a callback that was **already queued on the
6+
dispatcher** still runs after `Dispose()` and **directly reads the disposed field**
7+
(`_conn.ChangeDatabase(...)`). In real code this is an `ObjectDisposedException` or a
8+
read of torn state — the field-mediated cousin of the zombie-ViewModel leak. The
9+
defensive fix (the canonical one) is a disposed-flag guard at the top of the handler;
10+
relying on unsubscribe-ordering alone does not close the already-queued-callback race.
11+
12+
**What's new — the extractor catches this end-to-end.** The Roslyn extractor's
13+
field-mediated cross-method use-after-dispose pass (under `--flow-locals`) recognises
14+
an `IDisposable` field that is
15+
16+
1. disposed in this class's `Dispose()` / `DisposeAsync()` (the lifecycle release),
17+
2. directly read (`_field.Member`) inside a **live subscription target** — a method
18+
that is the RHS of a `+=` or the argument of a `.Subscribe(...)`, and whose
19+
subscription is **not** torn down by a matching `-=` (an unsubscribed callback
20+
cannot fire post-dispose, so it is exempt), and
21+
3. read in a handler with **no** `if (_disposed) return;` guard,
22+
23+
and lowers it to a synthetic `acquire`/`release`/`use` flow. That rides the existing
24+
OwnIR bridge — the same machinery the local-disposable and MemoryPool slices use — so
25+
the core raises **OWN002** ("use after release") with no new diagnostic and no second
26+
checker. `case.own` is the hand reduction of exactly that flow; on the real C# the
27+
`corpus-benchmark` job scores `before.cs` as caught (OWN002) and `after.cs` (the
28+
guarded fix) as silent.
29+
30+
**Precision (why it stays low-FP).** The check fires only on a field disposed in the
31+
dispose *lifecycle*, used in a *live* subscription target, with no guard, via a
32+
**direct** field member access. The guard exclusion is the canonical fix, so a fixed
33+
handler is silent; an unsubscribed (`-=`) or empty handler never fires; and an
34+
*indirect* use through a helper is deliberately **not** chased.
35+
36+
**Honesty / scope.** This catches the **direct** `_field.Member` read. Its sibling
37+
`handler-use-after-dispose` reaches the disposed state **indirectly** (`Refresh()`
38+
touches subscription-backed state) — the extractor does not follow that hop, so that
39+
case remains an honest extractor miss (a tracked recall gap, not a logic gap: its
40+
`case.own` reduction still fires OWN002). `case.own` here is a faithful hand reduction
41+
of the ownership logic; `before.cs` / `after.cs` are representative of the bug and its
42+
fix, not a verbatim copy of one PR.
43+
44+
Reference: [P-007](../../../docs/proposals/P-007-arraypool-span.md); the indirect twin
45+
is `handler-use-after-dispose`; the late-callback framing matches `zombie-viewmodel`.

frontend/roslyn/OwnSharp.Extractor/Program.cs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -907,6 +907,37 @@ static void EmitOverspans(ExpressionSyntax expr, HashSet<string> tracked, Semant
907907
_ => null,
908908
};
909909

910+
// The field name an expression refers to ONLY when it names a field of THIS object — a bare
911+
// `_f` or `this._f`, NOT `other._f` (a same-named field on a DIFFERENT receiver, which plain
912+
// text matching would conflate into a phantom release/use, CodeRabbit). Deliberately syntactic,
913+
// not symbol-bound: the field-UAF corpus uses types that do not resolve in the project-local
914+
// compilation, so binding on the field's TYPE is unreliable — the `this`/bare receiver shape is
915+
// exact regardless.
916+
static string? ThisFieldName(ExpressionSyntax expr) => expr switch
917+
{
918+
IdentifierNameSyntax id => id.Identifier.Text,
919+
MemberAccessExpressionSyntax m when m.Expression is ThisExpressionSyntax
920+
=> m.Name.Identifier.Text,
921+
_ => null,
922+
};
923+
924+
// Is there a disposed-flag early-return guard (`if (_disposed) return;`, `if (IsDisposed) return;`)
925+
// among a handler body's TOP-LEVEL statements that OPENS before source position `before`? Such a
926+
// guard makes a later disposed-field read safe (the canonical fix), so the field-UAF pass excludes
927+
// it. Tight on purpose (CodeRabbit/Codex): (1) the guard must PRECEDE the read — a guard only after
928+
// the read does not protect it, so that finding still stands; (2) the THEN branch must be an
929+
// IMMEDIATE `return` (the guard's own action), not a `return` buried in a nested/`else` branch; and
930+
// (3) the flag identifier matches "dispos" case-INsensitively, so the PascalCase `IsDisposed` form
931+
// is recognised as well as `_disposed`.
932+
static bool DisposedGuardBefore(BlockSyntax body, int before) =>
933+
body.Statements.OfType<IfStatementSyntax>().Any(ifs =>
934+
ifs.SpanStart < before
935+
&& ifs.Condition.DescendantNodesAndSelf().OfType<IdentifierNameSyntax>()
936+
.Any(id => id.Identifier.Text.Contains("ispos", StringComparison.OrdinalIgnoreCase))
937+
&& (ifs.Statement is ReturnStatementSyntax
938+
|| (ifs.Statement is BlockSyntax gb
939+
&& gb.Statements.FirstOrDefault() is ReturnStatementSyntax)));
940+
910941
// Is `t` the System.Buffers.ArrayPool<T> type — the Return-based pool we model?
911942
// Checked on the resolved SYMBOL, not the receiver's text, so an aliased receiver
912943
// (`ArrayPool<int> p = ArrayPool<int>.Shared; p.Rent(n)`) binds correctly and an
@@ -1872,6 +1903,123 @@ or ImplicitObjectCreationExpressionSyntax
18721903
}
18731904
}
18741905

1906+
// P-007 / WPF: a field-mediated cross-method USE-AFTER-DISPOSE. An IDisposable
1907+
// field disposed in this class's Dispose()/DisposeAsync() is then DIRECTLY read
1908+
// (`_f.Member`) in an event-handler method — a callback an external event source
1909+
// can still invoke AFTER the object is disposed (the very reason WPF handler
1910+
// leaks matter). With no `if (_disposed) return;` guard the handler touches a
1911+
// field already disposed: a use-after-dispose. We lower it to a synthetic
1912+
// acquire/release/use flow so the existing OwnIR bridge raises OWN002 at the
1913+
// field — no new diagnostic, no second checker (the synthetic-flow trick the
1914+
// MemoryPool slices use). Precise by construction to stay low-FP: fires only when
1915+
// (a) the field is disposed in the dispose LIFECYCLE (not an ad-hoc `_f.Dispose()`),
1916+
// (b) the touching method is a LIVE subscription target — RHS of a `+=` / arg of
1917+
// a `.Subscribe(...)` — whose subscription is NOT torn down (`-= handler`
1918+
// means the callback cannot fire post-dispose, so it is safe),
1919+
// (c) the method has no disposed-guard (the canonical fix silences it), and
1920+
// (d) the use is a DIRECT field member access (an INDIRECT use via a helper is
1921+
// deliberately not chased — that is the harder frontier, left honest).
1922+
// Gated on --flow-locals like the rest of the synthetic-flow emission.
1923+
if (flowLocals)
1924+
{
1925+
// IDisposable fields -> declaration line (the synthetic `acquire`).
1926+
var dispoFieldLine = new Dictionary<string, int>(StringComparer.Ordinal);
1927+
foreach (var fd in cls.Members.OfType<FieldDeclarationSyntax>())
1928+
{
1929+
if (fd.Modifiers.Any(mm => mm.IsKind(SyntaxKind.StaticKeyword)))
1930+
continue;
1931+
if (!IsDisposableType(fd.Declaration.Type.ToString()))
1932+
continue;
1933+
foreach (var v in fd.Declaration.Variables)
1934+
dispoFieldLine[v.Identifier.Text] = LineOf(v);
1935+
}
1936+
// field -> line of its `.Dispose()` INSIDE Dispose()/DisposeAsync() (the
1937+
// release event). Restricted to the dispose methods so an ordinary
1938+
// `_f.Dispose()` helper is not misread as object teardown.
1939+
var releasedAt = new Dictionary<string, int>(StringComparer.Ordinal);
1940+
if (dispoFieldLine.Count > 0)
1941+
foreach (var dm in cls.Members.OfType<MethodDeclarationSyntax>())
1942+
{
1943+
if (dm.Identifier.Text is not ("Dispose" or "DisposeAsync"))
1944+
continue;
1945+
foreach (var inv in dm.DescendantNodes().OfType<InvocationExpressionSyntax>())
1946+
if (inv.Expression is MemberAccessExpressionSyntax dmm
1947+
&& dmm.Name.Identifier.Text is "Dispose" or "DisposeAsync"
1948+
&& ThisFieldName(dmm.Expression) is { } df
1949+
&& dispoFieldLine.ContainsKey(df)
1950+
&& !releasedAt.ContainsKey(df))
1951+
releasedAt[df] = LineOf(inv);
1952+
}
1953+
if (releasedAt.Count > 0)
1954+
{
1955+
// handler method names that are LIVE subscription targets. `+=` subscriptions are
1956+
// keyed by SOURCE|handler so a `-=` removes only the MATCHING one — a handler still
1957+
// `+=`'d to another live source stays live (a name-only set would let one `-=` drop
1958+
// it globally, CodeRabbit/Codex). A `.Subscribe(handler)` token is released by
1959+
// disposing the token (the Rx idiom), not a `-=`, so those handlers are always live.
1960+
var liveEventKeys = new HashSet<string>(StringComparer.Ordinal);
1961+
foreach (var a in assigns)
1962+
if (IsHandler(a.Right) && FieldName(a.Right) is { } hn)
1963+
{
1964+
var key = $"{a.Left}|{hn}";
1965+
if (a.IsKind(SyntaxKind.AddAssignmentExpression)) liveEventKeys.Add(key);
1966+
else if (a.IsKind(SyntaxKind.SubtractAssignmentExpression)) liveEventKeys.Remove(key);
1967+
}
1968+
var subscribed = new HashSet<string>(
1969+
liveEventKeys.Select(k => k[(k.LastIndexOf('|') + 1)..]), StringComparer.Ordinal);
1970+
foreach (var inv in cls.DescendantNodes().OfType<InvocationExpressionSyntax>())
1971+
if (inv.Expression is MemberAccessExpressionSyntax sm
1972+
&& sm.Name.Identifier.Text == "Subscribe")
1973+
foreach (var arg in inv.ArgumentList.Arguments)
1974+
if (FieldName(arg.Expression) is { } hn)
1975+
subscribed.Add(hn);
1976+
1977+
foreach (var hm in cls.Members.OfType<MethodDeclarationSyntax>())
1978+
{
1979+
if (hm.Body is not { } hbody)
1980+
continue;
1981+
var hname = hm.Identifier.Text;
1982+
if (hname is "Dispose" or "DisposeAsync")
1983+
continue;
1984+
if (!subscribed.Contains(hname))
1985+
continue;
1986+
// the FIRST direct read of a disposed field of THIS class (`_f` / `this._f`,
1987+
// not `other._f`) in the handler, if any.
1988+
MemberAccessExpressionSyntax? use = null;
1989+
string? useField = null;
1990+
foreach (var ma in hbody.DescendantNodes().OfType<MemberAccessExpressionSyntax>())
1991+
{
1992+
if (ma.Name.Identifier.Text is "Dispose" or "DisposeAsync")
1993+
continue;
1994+
if (ThisFieldName(ma.Expression) is { } uf && releasedAt.ContainsKey(uf))
1995+
{
1996+
use = ma;
1997+
useField = uf;
1998+
break;
1999+
}
2000+
}
2001+
// no direct disposed-field read, or an opening disposed-guard PRECEDES it ->
2002+
// not a use-after-dispose (an INDIRECT use via a helper is left an honest miss).
2003+
// Otherwise emit ONE synthetic acquire/release/use flow -> OWN002 via the bridge.
2004+
if (use is null || useField is null)
2005+
continue;
2006+
if (DisposedGuardBefore(hbody, use.SpanStart))
2007+
continue;
2008+
flowFunctions.Add(new
2009+
{
2010+
name = $"{cls.Identifier.Text}.{hname}",
2011+
file,
2012+
body = new List<object>
2013+
{
2014+
new { op = "acquire", var = useField, line = dispoFieldLine[useField] },
2015+
new { op = "release", var = useField, line = releasedAt[useField] },
2016+
new { op = "use", var = useField, line = LineOf(use) },
2017+
},
2018+
});
2019+
}
2020+
}
2021+
}
2022+
18752023
// WPF004: a `X.Subscribe(...)` whose IDisposable result is ignored — the
18762024
// call stands as a bare statement (not assigned/returned/added), so the
18772025
// token is dropped and never disposed. Member-access only (`x.Subscribe`),

0 commit comments

Comments
 (0)