Skip to content

Commit 3fbd6c6

Browse files
committed
extractor: a static class subscriber can't over-promote (OWN014 FP)
A subscription to a process-lived static event (AppDomain.ProcessExit, Console.CancelKeyPress) from inside a `static class` is not an OWN014 region escape: a static class has NO instance, so the 'promotes the component instance to the source's lifetime' premise is vacuously false — its state is already process-lived by the language definition. Sound by guarantee, not heuristic. Extends the existing process-lived-subscriber exemption (the WPF `App` singleton, clsIsApp) with clsIsStatic, gated identically: only the source=="static" region escape, non-timers. Mined on CsvHelper's static ConsoleHost, whose ProcessExit/CancelKeyPress shutdown hooks were false OWN014s. New corpus fixture subscription-static-class-host: before.cs is an INSTANCE host subscribing this to Console.CancelKeyPress (real OWN014), after.cs is the same as a static class (silent). case.own models the token form (OWN001) like the sibling screentogif-systemevents-leak case. Newtonsoft TraceJsonReader._textWriter stays baselined: it is a JsonTextWriter over a StringWriter, no-op only because of what it wraps — recognising that needs wrapper-cascade analysis (unsound to blanket-exempt JsonTextWriter), so the honest call is to leave it. Re-pointing the oracle at CsvHelper to confirm the ConsoleHost FPs clear. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019846YSZ35c7CdkWQ1qX5gm
1 parent 74fcbc1 commit 3fbd6c6

10 files changed

Lines changed: 133 additions & 19 deletions

File tree

‎.github/workflows/oracle.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ on:
4545
push:
4646
branches:
4747
- claude/zen-pasteur-76hfs1
48+
- claude/mos-ownership-summary-n3q3j4
4849
paths:
4950
- corpus/oracle-target.txt
5051

‎corpus/oracle-fp-baseline.txt‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,10 @@ JamesNK/Newtonsoft.Json | TraceJsonReader.cs | OWN001 | _textWriter | no-op disp
4444
JamesNK/Newtonsoft.Json | JsonSerializer.cs | OWN001 | serializer.Error | intra-call self-subscription: the serializer is freshly created from the same JsonSerializerSettings whose .Error handler it subscribes; source and handler are co-lifetimed
4545

4646
# --- JoshClose/CsvHelper --------------------------------------------------------
47-
JoshClose/CsvHelper | ConsoleHost.cs | OWN014 | AppDomain.CurrentDomain.ProcessExit | non-product (docs-src/ doc-generator) + process-lived subscriber: ConsoleHost itself lives for the whole process, so promoting it to a process-lived event source changes nothing
48-
JoshClose/CsvHelper | ConsoleHost.cs | OWN014 | Console.CancelKeyPress | non-product (docs-src/ doc-generator) + process-lived subscriber: same — a process-lived host subscribing to a process-lived event source is not a lifetime leak
47+
# [fix-pending experiment] Both ConsoleHost entries are cleared by the static-class
48+
# subscriber exemption (clsIsStatic) — ConsoleHost is a `static class`, so the
49+
# process-lived subscription promotes no instance. Disabled here to confirm on a live
50+
# CsvHelper oracle run that they no longer fire; if confirmed they are deleted, if they
51+
# resurface they are restored.
52+
# JoshClose/CsvHelper | ConsoleHost.cs | OWN014 | AppDomain.CurrentDomain.ProcessExit | non-product (docs-src/ doc-generator) + process-lived subscriber: ConsoleHost itself lives for the whole process, so promoting it to a process-lived event source changes nothing
53+
# JoshClose/CsvHelper | ConsoleHost.cs | OWN014 | Console.CancelKeyPress | non-product (docs-src/ doc-generator) + process-lived subscriber: same — a process-lived host subscribing to a process-lived event source is not a lifetime leak

‎corpus/oracle-target.txt‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,7 @@
1313
# differentiation, on real cross-tool output rather than the selftest's fixtures.
1414
# Expect: #1 Own.NET-only (now OWN014), #2/#3/#4 dispose leaks in "Agree", 0
1515
# oracle-only.
16-
local:corpus/fixtures/systemevents-console
17-
build=SystemEventsLeak.csproj
16+
# [temporary] pointed at CsvHelper to confirm the static-class subscriber exemption
17+
# clears the ConsoleHost ProcessExit/CancelKeyPress FPs; restored after the run.
18+
JoshClose/CsvHelper
19+
include_tests=false
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using System;
2+
3+
// FIX: the host is a STATIC class — it has no instance, so the process-lived
4+
// subscription promotes nothing (a static class is already process-lived; its state
5+
// lives for the whole process by definition). The shutdown hook captures only
6+
// static/local state, never a `this`. Not a leak (mined on CsvHelper's static
7+
// `ConsoleHost`, whose `ProcessExit`/`CancelKeyPress` hooks are false positives).
8+
static class Host
9+
{
10+
static int _count;
11+
12+
public static void Attach()
13+
{
14+
Console.CancelKeyPress += (s, e) => _count++; // no instance to over-promote
15+
}
16+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using System;
2+
3+
// BUG: an INSTANCE host subscribes a `this`-capturing handler to the process-lived
4+
// static event `Console.CancelKeyPress` and never detaches it. The process-lived
5+
// source pins this Host instance for the whole process — an OWN014 region escape.
6+
sealed class Host
7+
{
8+
int _count;
9+
10+
public Host()
11+
{
12+
Console.CancelKeyPress += (s, e) => _count++; // captures this; never -=
13+
}
14+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// OwnLang model of the static-class shutdown-hook subscription. before.cs subscribes a
2+
// `this`-capturing handler to a process-lived static event (Console.CancelKeyPress) from
3+
// an INSTANCE host and never unsubscribes — a subscription leak. The real C# lands the
4+
// REGION form (OWN014 escape, static source outlives the instance); here it is modelled
5+
// in the equivalent TOKEN form as the generic OWN001 (a Subscription acquire with no
6+
// release), the shape test_corpus.py checks — exactly as the sibling
7+
// corpus/real-world/screentogif-systemevents-leak case does. The fix makes the host a
8+
// `static class` (no instance to promote), which the extractor's static-class subscriber
9+
// exemption drops; see notes.md for the recognition rule.
10+
module Corpus
11+
resource Subscription {
12+
acquire Subscribe
13+
release Unsubscribe
14+
kind "subscription token"
15+
}
16+
fn AttachShutdownHook(source: int) {
17+
let token = acquire Subscription(source); // Console.CancelKeyPress += (s, e) => _count++
18+
// no `release token;` — the instance host never `-=`'s the process-lived source (leak)
19+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
OWN001
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# subscription-static-class-host
2+
3+
A subscription to a process-lived static event (`AppDomain.ProcessExit`,
4+
`Console.CancelKeyPress`) from inside a **`static class`** is not an OWN014 region
5+
escape. Mined on CsvHelper's static `ConsoleHost`:
6+
7+
```csharp
8+
public static class ConsoleHost
9+
{
10+
// ...
11+
AppDomain.CurrentDomain.ProcessExit += delegate { ShutDown(); };
12+
Console.CancelKeyPress += (sender, eventArgs) => { ShutDown(); eventArgs.Cancel = true; };
13+
}
14+
```
15+
16+
OWN014's premise is that a long-lived source **promotes the subscribing component
17+
instance** to its lifetime (the zombie pattern). A `static class` has **no instance** —
18+
the promotion target does not exist, and the class's own state is process-lived by the
19+
language definition. So the finding is vacuously false.
20+
21+
- **before.cs** — an **instance** `Host` subscribes a `this`-capturing handler to
22+
`Console.CancelKeyPress` (process-lived static event), never `-=` → real `OWN014`
23+
(the source pins the instance for the whole process).
24+
- **after.cs** — the host is a `static class` → no instance to promote → **silent**.
25+
26+
## Recognition rule
27+
28+
The static-source region-escape exemption already drops a subscription whose subscriber
29+
is the process-lived WPF `App` singleton (`IsProcessLivedApplication`) — it cannot be
30+
over-promoted. This adds the analogous, **language-guaranteed** case: a `static class`
31+
subscriber (`clsIsStatic`). Both gate the same `source == "static"` (process-lived
32+
source) region escape, and both are scoped to NON-timers (a never-stopped timer is a
33+
real leak regardless of who owns it).
34+
35+
This is sound, not a heuristic: `static class` is a compile-time guarantee of no
36+
instances. It does **not** suppress an instance class's subscription, nor a static
37+
class's subscription to a *shorter-lived* (non-static) source — only the process-lived
38+
source escape, where there is provably nothing to over-promote.

‎docs/notes/oracle-known-fps.md‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,25 @@ reason: we and the oracles occupy orthogonal niches.
2323

2424
| disposition | count | what happens on re-run |
2525
|---|---:|---|
26-
| **Fixed in the extractor** | 6 | no longer fire (5 NLog `WaitForDispose` timers + protobuf `XsltOptions` self-cycle — see below) |
27-
| **Baselined FP** | 6 | moved to "Known FP (baselined)", out of the triage queue |
26+
| **Fixed in the extractor** | 8 | no longer fire (5 NLog `WaitForDispose` timers + protobuf `XsltOptions` self-cycle + 2 CsvHelper static-class hooks — see below) |
27+
| **Baselined FP** | 4 | moved to "Known FP (baselined)", out of the triage queue |
2828
| **Non-product (path filter)** | 2 | dropped by `--exclude-tests` (`unittest` rule) |
2929
| **True positive — kept visible** | 4 | stays in "Own.NET only" (real catch, oracle can't express) |
3030
| **True-but-benign — kept, baselined-as-sample** | 2 | (protobuf `assorted/` samples) baselined as non-product |
3131

32-
The 6 baselined FPs + the 2 non-product-sample reals = 8 findings, covered by
33-
**7 rules** in `corpus/oracle-fp-baseline.txt` (the two `NetTranscoder` copies
32+
The 4 baselined FPs + the 2 non-product-sample reals = 6 findings, covered by
33+
**5 rules** in `corpus/oracle-fp-baseline.txt` (the two `NetTranscoder` copies
3434
share one basename-keyed rule); the 2 test-base findings are the `--exclude-tests`
3535
drops; the 4 true positives are deliberately **not** suppressed.
3636

37+
**Update (extractor fix landed — CsvHelper static-class hooks).** Both
38+
`ConsoleHost` findings (`AppDomain.ProcessExit` / `Console.CancelKeyPress` shutdown
39+
hooks) are now **fixed at the source** by the static-class subscriber exemption
40+
(`clsIsStatic`): `ConsoleHost` is a `static class`, which has no instance for the
41+
process-lived subscription to over-promote, so OWN014's premise is vacuously false
42+
(a language guarantee, not a heuristic). See root-cause #3. Corpus fixture:
43+
`subscription-static-class-host`.
44+
3745
**Update (extractor fix landed — protobuf self-cycle).** `CommandLineOptions.XsltOptions.
3846
XsltMessageEncountered` — a `this`-capturing handler subscribed to an event on
3947
`XsltOptions`, a get-only property over a constructed field the class owns — is now
@@ -167,12 +175,16 @@ entry and let the oracle re-confirm clean.
167175
get-only property over a member the class constructs, is the same collectable
168176
self-cycle as the owned field directly (get-only required: a settable property could
169177
be reassigned to an injected object). Cleared protobuf `XsltOptions` on a live re-run
170-
(own-only 0); corpus fixture `subscription-self-owned-property`. **Still open:**
171-
Newtonsoft `serializer.Error` — the source is a returned `Create()` result (escapes)
172-
and the handler is a parameter's delegate; the source's lifetime relative to the
173-
handler is genuinely unprovable syntactically, so the "may outlive" warning is honest
174-
(baselined, not a clear FP). CsvHelper's process-lived host needs a "subscriber is
175-
itself process-lived" signal — still open. See
178+
(own-only 0); corpus fixture `subscription-self-owned-property`. **Second shipped
179+
fix:** CsvHelper's process-lived host is now handled by the `clsIsStatic` exemption —
180+
a `static class` subscriber has no instance to over-promote, so a process-lived
181+
(static-source) subscription inside one is never an OWN014 escape (language guarantee).
182+
Cleared both `ConsoleHost` hooks; corpus fixture `subscription-static-class-host`.
183+
**Still open:** Newtonsoft `serializer.Error` — the source is a returned `Create()`
184+
result (escapes) and the handler is a parameter's delegate; the source's lifetime
185+
relative to the handler is genuinely unprovable syntactically, so the "may outlive"
186+
warning is honest (baselined, not a clear FP). A process-lived *instance* host (a
187+
non-static singleton) still needs a lifetime signal — open. See
176188
[`subscription-leaks-and-profiles.md`](subscription-leaks-and-profiles.md).
177189

178190
4. **Non-product trees** *(protobuf `assorted/`, CsvHelper `docs-src/`, protobuf

‎frontend/roslyn/OwnSharp.Extractor/Program.cs‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3279,6 +3279,12 @@ or ImplicitObjectCreationExpressionSyntax
32793279
// Is this class the process-lived WPF application object? Used to drop the
32803280
// static-source region escape (OWN014) — `App` cannot be over-promoted.
32813281
var clsIsApp = IsProcessLivedApplication(cls);
3282+
// A `static class` has NO instance, so a subscription in it can never promote a
3283+
// *component instance* to a longer lifetime — OWN014's premise is vacuously false
3284+
// (the class's own state is already process-lived). Sound by the language
3285+
// guarantee, not a heuristic. Mined on CsvHelper's static `ConsoleHost`, whose
3286+
// `AppDomain.ProcessExit` / `Console.CancelKeyPress` shutdown hooks are FPs.
3287+
var clsIsStatic = cls.Modifiers.Any(mod => mod.IsKind(SyntaxKind.StaticKeyword));
32823288

32833289
var subs = new List<object>();
32843290
foreach (var a in assigns)
@@ -3320,12 +3326,12 @@ or ImplicitObjectCreationExpressionSyntax
33203326
: SubscriptionSourceKind(a.Left, ev, model);
33213327
if (source == "local")
33223328
continue;
3323-
// Process-lived subscriber (the WPF `App` singleton): a static-source
3324-
// subscription promotes nothing — `App` already lives for the whole
3325-
// process — so the region escape (OWN014) is a false positive. Scoped
3326-
// to NON-timers: a timer is forced to source "static" above, but a
3329+
// Process-lived subscriber: the WPF `App` singleton, OR any `static class`
3330+
// (which has no instance to over-promote). A static-source subscription
3331+
// promotes nothing, so the region escape (OWN014) is a false positive.
3332+
// Scoped to NON-timers: a timer is forced to source "static" above, but a
33273333
// never-stopped timer in `App` is still a real leak (CodeRabbit).
3328-
if (!isTimer && source == "static" && clsIsApp)
3334+
if (!isTimer && source == "static" && (clsIsApp || clsIsStatic))
33293335
continue;
33303336
var released = unsub.Contains($"{a.Left}|{a.Right}")
33313337
|| (isTimer && Receiver(a.Left) is { } recv && stopped.Contains(recv));

0 commit comments

Comments
 (0)