Skip to content

Commit 6eb172d

Browse files
committed
fix(extractor): exempt process-host AppDomain event subscriptions from OWN014 (mined Npgsql)
Mining npgsql/npgsql surfaced this: PoolManager's static ctor subscribes `AppDomain.CurrentDomain.{DomainUnload,ProcessExit} += (_,_) => ClearAll()` — a deliberate "close idle connectors on appdomain unload (web-app redeployment)" hook (#491) — and the detector reported it as an OWN014 region escape. But subscribing to a process-host AppDomain event is never a leak: ProcessExit/DomainUnload run at shutdown, UnhandledException / FirstChanceException on every unhandled throw, and the AppDomain IS the process host — the handler is MEANT to live for the whole process. Promoting the subscriber to "the AppDomain's lifetime" is the intent, not a leak. IsProcessLifetimeAppDomainEvent exempts these four System.AppDomain events from the OWN014 region escape (alongside the self-owned-source and static-handler exemptions). Scoped to the event's resolved declaring type, so a project's own `AppDomain`-named type is not matched, and a non-AppDomain process-lived static event still escapes. Regression sample AppDomainShutdownSample.cs: ShutdownCleanup (ProcessExit/DomainUnload/ UnhandledException lambdas) stays silent; NonAppDomainSubscriber (a lambda on a non-AppDomain static event) still raises OWN014. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
1 parent b787ffc commit 6eb172d

3 files changed

Lines changed: 69 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ jobs:
140140
frontend/roslyn/samples/ViewOwnsVmSample.xaml.cs \
141141
frontend/roslyn/samples/InjectedDcViewSample.xaml.cs \
142142
frontend/roslyn/samples/ResolvedDisposableSample.cs \
143+
frontend/roslyn/samples/AppDomainShutdownSample.cs \
143144
-o "$RUNNER_TEMP/facts.json"
144145
cat "$RUNNER_TEMP/facts.json"
145146
- name: Check facts through the core
@@ -301,6 +302,15 @@ jobs:
301302
|| { echo "FAIL: expected OWN014 region escape on the static-event instance subscription"; exit 1; }
302303
echo "$out" | grep -q "region escape" \
303304
|| { echo "FAIL: expected the region-escape wording on the static-event capture"; exit 1; }
305+
# P-004 process-lifetime AppDomain-event exemption (mined: Npgsql PoolManager): a
306+
# subscription to a process-host AppDomain event (ProcessExit/DomainUnload/Unhandled-
307+
# Exception) is a shutdown/diagnostics hook meant to live for the process -> NOT a
308+
# region escape -> silent. A non-AppDomain static event with a lambda still escapes.
309+
if echo "$out" | grep -q "ShutdownCleanup"; then
310+
echo "FAIL: an AppDomain process-lifetime event subscription was wrongly reported as a region escape"; exit 1
311+
fi
312+
echo "$out" | grep -qE "NonAppDomainSubscriber.*OWN014|OWN014.*NonAppDomainSubscriber" \
313+
|| { echo "FAIL: a lambda on a non-AppDomain static event must still raise OWN014 (exemption stays scoped)"; exit 1; }
304314
# the unsubscribed variant (a matching `-=`, released capture) is mitigated
305315
# -> silent. Must NOT be reported.
306316
if echo "$out" | grep -q "CleanStaticEventViewModel"; then

frontend/roslyn/OwnSharp.Extractor/Program.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,19 @@ static bool IsStaticHandler(ExpressionSyntax right, SemanticModel model) =>
190190
IsHandler(right)
191191
&& model.GetSymbolInfo(right).Symbol is IMethodSymbol { IsStatic: true };
192192

193+
// P-004 process-lifetime exemption: a subscription to a PROCESS-HOST `System.AppDomain`
194+
// event — ProcessExit / DomainUnload (shutdown cleanup hooks) or UnhandledException /
195+
// FirstChanceException (process-wide diagnostics) — is never a region escape. The handler
196+
// is MEANT to live for the whole process: it runs at shutdown, or on every unhandled throw,
197+
// and the AppDomain IS the process host. Promoting the subscriber to "the AppDomain's
198+
// lifetime" is therefore the intent, not a leak. Mined: Npgsql's PoolManager static ctor
199+
// `AppDomain.CurrentDomain.{DomainUnload,ProcessExit} += (_,_) => ClearAll()` — a deliberate
200+
// "close idle connectors on appdomain unload (web-app redeployment)" hook (#491).
201+
static bool IsProcessLifetimeAppDomainEvent(IEventSymbol ev) =>
202+
ev.Name is "ProcessExit" or "DomainUnload" or "UnhandledException" or "FirstChanceException"
203+
&& ev.ContainingType is { Name: "AppDomain" } ct
204+
&& IsInNamespace(ct, "System");
205+
193206
// P-004 process-lived-subscriber exemption: the WPF application object (`App`) is a
194207
// process-lived singleton — exactly one instance, created at startup, alive until
195208
// the process exits. Subscribing it to a process-lived static event
@@ -2065,8 +2078,13 @@ or ImplicitObjectCreationExpressionSyntax
20652078
// constructs) — the source<->this cycle is GC-collectable;
20662079
// - static handler — a static method has a null delegate target,
20672080
// so no instance is retained and nothing can leak.
2081+
// - a process-host AppDomain event (ProcessExit/DomainUnload/Unhandled-
2082+
// Exception/FirstChanceException) — the handler is meant to live for the
2083+
// whole process, so the "escape" is the intent, not a leak (mined: Npgsql
2084+
// PoolManager's `AppDomain.CurrentDomain.ProcessExit += …` shutdown hook).
20682085
if (!isTimer && (IsSelfOwnedSource(a.Left, ev, model, selfOwned)
2069-
|| IsStaticHandler(a.Right, model)))
2086+
|| IsStaticHandler(a.Right, model)
2087+
|| IsProcessLifetimeAppDomainEvent(ev)))
20702088
continue;
20712089
// P-004 tiering: a local-variable source is method-bounded — it
20722090
// cannot outlive `this`, so it is not a heap leak; drop it (the same
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using System;
2+
3+
namespace Own.Samples;
4+
5+
// P-004 process-lifetime AppDomain-event exemption (mined: Npgsql PoolManager). Subscribing to
6+
// AppDomain's process-host events — ProcessExit / DomainUnload (shutdown cleanup hooks) and
7+
// UnhandledException / FirstChanceException (process-wide diagnostics) — is never a region
8+
// escape: the handler is meant to live for the whole process. Must be SILENT. Contrast:
9+
// NonAppDomainSubscriber below — a non-AppDomain static event with a lambda still raises OWN014,
10+
// proving the exemption keys off the AppDomain source, not any process-lived static event.
11+
12+
public sealed class ShutdownCleanup
13+
{
14+
public ShutdownCleanup()
15+
{
16+
AppDomain.CurrentDomain.ProcessExit += (_, _) => Cleanup(); // shutdown hook -> SILENT
17+
AppDomain.CurrentDomain.DomainUnload += (_, _) => Cleanup(); // shutdown hook -> SILENT
18+
AppDomain.CurrentDomain.UnhandledException += (_, _) => Cleanup(); // process diagnostics -> SILENT
19+
}
20+
21+
private static void Cleanup() { }
22+
}
23+
24+
public static class SomeBus
25+
{
26+
public static event EventHandler? Pinged;
27+
28+
public static void Raise() => Pinged?.Invoke(null, EventArgs.Empty);
29+
}
30+
31+
// Control: a lambda on a NON-AppDomain process-lived (static) event -> region escape -> must WARN.
32+
public sealed class NonAppDomainSubscriber
33+
{
34+
public NonAppDomainSubscriber()
35+
{
36+
SomeBus.Pinged += (_, _) => Handle(); // static event, lambda, not AppDomain -> OWN014
37+
}
38+
39+
private static void Handle() { }
40+
}

0 commit comments

Comments
 (0)