From 3fbd6c619276edbb0f63fedb5546dd4e64b61915 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 05:08:18 +0000 Subject: [PATCH 1/3] extractor: a static class subscriber can't over-promote (OWN014 FP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_019846YSZ35c7CdkWQ1qX5gm --- .github/workflows/oracle.yml | 1 + corpus/oracle-fp-baseline.txt | 9 ++++- corpus/oracle-target.txt | 6 ++- .../subscription-static-class-host/after.cs | 16 ++++++++ .../subscription-static-class-host/before.cs | 14 +++++++ .../subscription-static-class-host/case.own | 19 ++++++++++ .../expected-diagnostics.txt | 1 + .../subscription-static-class-host/notes.md | 38 +++++++++++++++++++ docs/notes/oracle-known-fps.md | 32 +++++++++++----- frontend/roslyn/OwnSharp.Extractor/Program.cs | 16 +++++--- 10 files changed, 133 insertions(+), 19 deletions(-) create mode 100644 corpus/real-world/subscription-static-class-host/after.cs create mode 100644 corpus/real-world/subscription-static-class-host/before.cs create mode 100644 corpus/real-world/subscription-static-class-host/case.own create mode 100644 corpus/real-world/subscription-static-class-host/expected-diagnostics.txt create mode 100644 corpus/real-world/subscription-static-class-host/notes.md diff --git a/.github/workflows/oracle.yml b/.github/workflows/oracle.yml index 0a290055..cfc3fc66 100644 --- a/.github/workflows/oracle.yml +++ b/.github/workflows/oracle.yml @@ -45,6 +45,7 @@ on: push: branches: - claude/zen-pasteur-76hfs1 + - claude/mos-ownership-summary-n3q3j4 paths: - corpus/oracle-target.txt diff --git a/corpus/oracle-fp-baseline.txt b/corpus/oracle-fp-baseline.txt index d112123f..e08c3dd3 100644 --- a/corpus/oracle-fp-baseline.txt +++ b/corpus/oracle-fp-baseline.txt @@ -44,5 +44,10 @@ JamesNK/Newtonsoft.Json | TraceJsonReader.cs | OWN001 | _textWriter | no-op disp 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 # --- JoshClose/CsvHelper -------------------------------------------------------- -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 -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 +# [fix-pending experiment] Both ConsoleHost entries are cleared by the static-class +# subscriber exemption (clsIsStatic) — ConsoleHost is a `static class`, so the +# process-lived subscription promotes no instance. Disabled here to confirm on a live +# CsvHelper oracle run that they no longer fire; if confirmed they are deleted, if they +# resurface they are restored. +# 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 +# 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 diff --git a/corpus/oracle-target.txt b/corpus/oracle-target.txt index 52cb6d64..de817f4e 100644 --- a/corpus/oracle-target.txt +++ b/corpus/oracle-target.txt @@ -13,5 +13,7 @@ # differentiation, on real cross-tool output rather than the selftest's fixtures. # Expect: #1 Own.NET-only (now OWN014), #2/#3/#4 dispose leaks in "Agree", 0 # oracle-only. -local:corpus/fixtures/systemevents-console -build=SystemEventsLeak.csproj +# [temporary] pointed at CsvHelper to confirm the static-class subscriber exemption +# clears the ConsoleHost ProcessExit/CancelKeyPress FPs; restored after the run. +JoshClose/CsvHelper +include_tests=false diff --git a/corpus/real-world/subscription-static-class-host/after.cs b/corpus/real-world/subscription-static-class-host/after.cs new file mode 100644 index 00000000..90d83881 --- /dev/null +++ b/corpus/real-world/subscription-static-class-host/after.cs @@ -0,0 +1,16 @@ +using System; + +// FIX: the host is a STATIC class — it has no instance, so the process-lived +// subscription promotes nothing (a static class is already process-lived; its state +// lives for the whole process by definition). The shutdown hook captures only +// static/local state, never a `this`. Not a leak (mined on CsvHelper's static +// `ConsoleHost`, whose `ProcessExit`/`CancelKeyPress` hooks are false positives). +static class Host +{ + static int _count; + + public static void Attach() + { + Console.CancelKeyPress += (s, e) => _count++; // no instance to over-promote + } +} diff --git a/corpus/real-world/subscription-static-class-host/before.cs b/corpus/real-world/subscription-static-class-host/before.cs new file mode 100644 index 00000000..a2099a37 --- /dev/null +++ b/corpus/real-world/subscription-static-class-host/before.cs @@ -0,0 +1,14 @@ +using System; + +// BUG: an INSTANCE host subscribes a `this`-capturing handler to the process-lived +// static event `Console.CancelKeyPress` and never detaches it. The process-lived +// source pins this Host instance for the whole process — an OWN014 region escape. +sealed class Host +{ + int _count; + + public Host() + { + Console.CancelKeyPress += (s, e) => _count++; // captures this; never -= + } +} diff --git a/corpus/real-world/subscription-static-class-host/case.own b/corpus/real-world/subscription-static-class-host/case.own new file mode 100644 index 00000000..84e71716 --- /dev/null +++ b/corpus/real-world/subscription-static-class-host/case.own @@ -0,0 +1,19 @@ +// OwnLang model of the static-class shutdown-hook subscription. before.cs subscribes a +// `this`-capturing handler to a process-lived static event (Console.CancelKeyPress) from +// an INSTANCE host and never unsubscribes — a subscription leak. The real C# lands the +// REGION form (OWN014 escape, static source outlives the instance); here it is modelled +// in the equivalent TOKEN form as the generic OWN001 (a Subscription acquire with no +// release), the shape test_corpus.py checks — exactly as the sibling +// corpus/real-world/screentogif-systemevents-leak case does. The fix makes the host a +// `static class` (no instance to promote), which the extractor's static-class subscriber +// exemption drops; see notes.md for the recognition rule. +module Corpus +resource Subscription { + acquire Subscribe + release Unsubscribe + kind "subscription token" +} +fn AttachShutdownHook(source: int) { + let token = acquire Subscription(source); // Console.CancelKeyPress += (s, e) => _count++ + // no `release token;` — the instance host never `-=`'s the process-lived source (leak) +} diff --git a/corpus/real-world/subscription-static-class-host/expected-diagnostics.txt b/corpus/real-world/subscription-static-class-host/expected-diagnostics.txt new file mode 100644 index 00000000..ed2a1929 --- /dev/null +++ b/corpus/real-world/subscription-static-class-host/expected-diagnostics.txt @@ -0,0 +1 @@ +OWN001 diff --git a/corpus/real-world/subscription-static-class-host/notes.md b/corpus/real-world/subscription-static-class-host/notes.md new file mode 100644 index 00000000..2fc2c4d9 --- /dev/null +++ b/corpus/real-world/subscription-static-class-host/notes.md @@ -0,0 +1,38 @@ +# subscription-static-class-host + +A subscription to a process-lived static event (`AppDomain.ProcessExit`, +`Console.CancelKeyPress`) from inside a **`static class`** is not an OWN014 region +escape. Mined on CsvHelper's static `ConsoleHost`: + +```csharp +public static class ConsoleHost +{ + // ... + AppDomain.CurrentDomain.ProcessExit += delegate { ShutDown(); }; + Console.CancelKeyPress += (sender, eventArgs) => { ShutDown(); eventArgs.Cancel = true; }; +} +``` + +OWN014's premise is that a long-lived source **promotes the subscribing component +instance** to its lifetime (the zombie pattern). A `static class` has **no instance** — +the promotion target does not exist, and the class's own state is process-lived by the +language definition. So the finding is vacuously false. + +- **before.cs** — an **instance** `Host` subscribes a `this`-capturing handler to + `Console.CancelKeyPress` (process-lived static event), never `-=` → real `OWN014` + (the source pins the instance for the whole process). +- **after.cs** — the host is a `static class` → no instance to promote → **silent**. + +## Recognition rule + +The static-source region-escape exemption already drops a subscription whose subscriber +is the process-lived WPF `App` singleton (`IsProcessLivedApplication`) — it cannot be +over-promoted. This adds the analogous, **language-guaranteed** case: a `static class` +subscriber (`clsIsStatic`). Both gate the same `source == "static"` (process-lived +source) region escape, and both are scoped to NON-timers (a never-stopped timer is a +real leak regardless of who owns it). + +This is sound, not a heuristic: `static class` is a compile-time guarantee of no +instances. It does **not** suppress an instance class's subscription, nor a static +class's subscription to a *shorter-lived* (non-static) source — only the process-lived +source escape, where there is provably nothing to over-promote. diff --git a/docs/notes/oracle-known-fps.md b/docs/notes/oracle-known-fps.md index 1aa8da53..45002fd9 100644 --- a/docs/notes/oracle-known-fps.md +++ b/docs/notes/oracle-known-fps.md @@ -23,17 +23,25 @@ reason: we and the oracles occupy orthogonal niches. | disposition | count | what happens on re-run | |---|---:|---| -| **Fixed in the extractor** | 6 | no longer fire (5 NLog `WaitForDispose` timers + protobuf `XsltOptions` self-cycle — see below) | -| **Baselined FP** | 6 | moved to "Known FP (baselined)", out of the triage queue | +| **Fixed in the extractor** | 8 | no longer fire (5 NLog `WaitForDispose` timers + protobuf `XsltOptions` self-cycle + 2 CsvHelper static-class hooks — see below) | +| **Baselined FP** | 4 | moved to "Known FP (baselined)", out of the triage queue | | **Non-product (path filter)** | 2 | dropped by `--exclude-tests` (`unittest` rule) | | **True positive — kept visible** | 4 | stays in "Own.NET only" (real catch, oracle can't express) | | **True-but-benign — kept, baselined-as-sample** | 2 | (protobuf `assorted/` samples) baselined as non-product | -The 6 baselined FPs + the 2 non-product-sample reals = 8 findings, covered by -**7 rules** in `corpus/oracle-fp-baseline.txt` (the two `NetTranscoder` copies +The 4 baselined FPs + the 2 non-product-sample reals = 6 findings, covered by +**5 rules** in `corpus/oracle-fp-baseline.txt` (the two `NetTranscoder` copies share one basename-keyed rule); the 2 test-base findings are the `--exclude-tests` drops; the 4 true positives are deliberately **not** suppressed. +**Update (extractor fix landed — CsvHelper static-class hooks).** Both +`ConsoleHost` findings (`AppDomain.ProcessExit` / `Console.CancelKeyPress` shutdown +hooks) are now **fixed at the source** by the static-class subscriber exemption +(`clsIsStatic`): `ConsoleHost` is a `static class`, which has no instance for the +process-lived subscription to over-promote, so OWN014's premise is vacuously false +(a language guarantee, not a heuristic). See root-cause #3. Corpus fixture: +`subscription-static-class-host`. + **Update (extractor fix landed — protobuf self-cycle).** `CommandLineOptions.XsltOptions. XsltMessageEncountered` — a `this`-capturing handler subscribed to an event on `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. get-only property over a member the class constructs, is the same collectable self-cycle as the owned field directly (get-only required: a settable property could be reassigned to an injected object). Cleared protobuf `XsltOptions` on a live re-run - (own-only 0); corpus fixture `subscription-self-owned-property`. **Still open:** - Newtonsoft `serializer.Error` — the source is a returned `Create()` result (escapes) - and the handler is a parameter's delegate; the source's lifetime relative to the - handler is genuinely unprovable syntactically, so the "may outlive" warning is honest - (baselined, not a clear FP). CsvHelper's process-lived host needs a "subscriber is - itself process-lived" signal — still open. See + (own-only 0); corpus fixture `subscription-self-owned-property`. **Second shipped + fix:** CsvHelper's process-lived host is now handled by the `clsIsStatic` exemption — + a `static class` subscriber has no instance to over-promote, so a process-lived + (static-source) subscription inside one is never an OWN014 escape (language guarantee). + Cleared both `ConsoleHost` hooks; corpus fixture `subscription-static-class-host`. + **Still open:** Newtonsoft `serializer.Error` — the source is a returned `Create()` + result (escapes) and the handler is a parameter's delegate; the source's lifetime + relative to the handler is genuinely unprovable syntactically, so the "may outlive" + warning is honest (baselined, not a clear FP). A process-lived *instance* host (a + non-static singleton) still needs a lifetime signal — open. See [`subscription-leaks-and-profiles.md`](subscription-leaks-and-profiles.md). 4. **Non-product trees** *(protobuf `assorted/`, CsvHelper `docs-src/`, protobuf diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 6976abf4..9814a20e 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -3279,6 +3279,12 @@ or ImplicitObjectCreationExpressionSyntax // Is this class the process-lived WPF application object? Used to drop the // static-source region escape (OWN014) — `App` cannot be over-promoted. var clsIsApp = IsProcessLivedApplication(cls); + // A `static class` has NO instance, so a subscription in it can never promote a + // *component instance* to a longer lifetime — OWN014's premise is vacuously false + // (the class's own state is already process-lived). Sound by the language + // guarantee, not a heuristic. Mined on CsvHelper's static `ConsoleHost`, whose + // `AppDomain.ProcessExit` / `Console.CancelKeyPress` shutdown hooks are FPs. + var clsIsStatic = cls.Modifiers.Any(mod => mod.IsKind(SyntaxKind.StaticKeyword)); var subs = new List(); foreach (var a in assigns) @@ -3320,12 +3326,12 @@ or ImplicitObjectCreationExpressionSyntax : SubscriptionSourceKind(a.Left, ev, model); if (source == "local") continue; - // Process-lived subscriber (the WPF `App` singleton): a static-source - // subscription promotes nothing — `App` already lives for the whole - // process — so the region escape (OWN014) is a false positive. Scoped - // to NON-timers: a timer is forced to source "static" above, but a + // Process-lived subscriber: the WPF `App` singleton, OR any `static class` + // (which has no instance to over-promote). A static-source subscription + // promotes nothing, so the region escape (OWN014) is a false positive. + // Scoped to NON-timers: a timer is forced to source "static" above, but a // never-stopped timer in `App` is still a real leak (CodeRabbit). - if (!isTimer && source == "static" && clsIsApp) + if (!isTimer && source == "static" && (clsIsApp || clsIsStatic)) continue; var released = unsub.Contains($"{a.Left}|{a.Right}") || (isTimer && Receiver(a.Left) is { } recv && stopped.Contains(recv)); From 6dd6ad5e6d99ec9be25893ff94eea4e046c9f19f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 05:14:07 +0000 Subject: [PATCH 2/3] oracle: confirm static-class exemption on CsvHelper, trim baseline + restore scaffolding Live CsvHelper oracle re-run confirms the clsIsStatic exemption clears both ConsoleHost hooks (AppDomain.ProcessExit / Console.CancelKeyPress) at the source: Own.NET leak total 2 -> 0, both findings absent from own-only and baselined. Delete the two CsvHelper baseline entries (no CsvHelper FP entries remain). Restore dev scaffolding: oracle-target.txt back to the systemevents-console fixture, remove the branch from the oracle.yml push filter. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019846YSZ35c7CdkWQ1qX5gm --- .github/workflows/oracle.yml | 1 - corpus/oracle-fp-baseline.txt | 12 +++++------- corpus/oracle-target.txt | 6 ++---- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/.github/workflows/oracle.yml b/.github/workflows/oracle.yml index cfc3fc66..0a290055 100644 --- a/.github/workflows/oracle.yml +++ b/.github/workflows/oracle.yml @@ -45,7 +45,6 @@ on: push: branches: - claude/zen-pasteur-76hfs1 - - claude/mos-ownership-summary-n3q3j4 paths: - corpus/oracle-target.txt diff --git a/corpus/oracle-fp-baseline.txt b/corpus/oracle-fp-baseline.txt index e08c3dd3..af1ae53e 100644 --- a/corpus/oracle-fp-baseline.txt +++ b/corpus/oracle-fp-baseline.txt @@ -44,10 +44,8 @@ JamesNK/Newtonsoft.Json | TraceJsonReader.cs | OWN001 | _textWriter | no-op disp 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 # --- JoshClose/CsvHelper -------------------------------------------------------- -# [fix-pending experiment] Both ConsoleHost entries are cleared by the static-class -# subscriber exemption (clsIsStatic) — ConsoleHost is a `static class`, so the -# process-lived subscription promotes no instance. Disabled here to confirm on a live -# CsvHelper oracle run that they no longer fire; if confirmed they are deleted, if they -# resurface they are restored. -# 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 -# 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 +# Both ConsoleHost hooks (AppDomain.ProcessExit / Console.CancelKeyPress) were here, but +# the static-class subscriber exemption (clsIsStatic) now clears them at the source — +# ConsoleHost is a `static class`, no instance to over-promote. Confirmed cleared on a +# live CsvHelper oracle run (own-only 0, both findings absent from own-only and +# baselined) — deleted. No CsvHelper FP entries remain. diff --git a/corpus/oracle-target.txt b/corpus/oracle-target.txt index de817f4e..52cb6d64 100644 --- a/corpus/oracle-target.txt +++ b/corpus/oracle-target.txt @@ -13,7 +13,5 @@ # differentiation, on real cross-tool output rather than the selftest's fixtures. # Expect: #1 Own.NET-only (now OWN014), #2/#3/#4 dispose leaks in "Agree", 0 # oracle-only. -# [temporary] pointed at CsvHelper to confirm the static-class subscriber exemption -# clears the ConsoleHost ProcessExit/CancelKeyPress FPs; restored after the run. -JoshClose/CsvHelper -include_tests=false +local:corpus/fixtures/systemevents-console +build=SystemEventsLeak.csproj From 488d505ddf72b909663fffb58048a36bcb2fb64d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 05:23:39 +0000 Subject: [PATCH 3/3] Revert static-class subscriber exemption (unsound; #157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both Codex and CodeRabbit independently flagged the clsIsStatic exemption as unsound: it skipped EVERY static-source subscription in a static class without a capture check, silently dropping real captured-local leaks — e.g. static class Foo { void Attach(VM vm) { SystemEvents.UserPreferenceChanged += (_, _) => vm.Refresh(); } } where the process-lived event pins the captured vm forever (was OWN014). A sound narrowing (suppress only when the handler captures nothing) would not clear the CsvHelper ConsoleHost target either — its shutdown hooks capture cts/resetEvent locals — so the static-class signal cannot soundly distinguish that FP from a real captured-local leak. CsvHelper ConsoleHost therefore stays baselined, same bucket as Newtonsoft serializer.Error. Reverts the extractor change, the subscription-static-class-host fixture, the doc update, and restores the two CsvHelper ConsoleHost baseline entries — branch returns to main. PR #157 is closed (no net change). Caught pre-merge by review. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019846YSZ35c7CdkWQ1qX5gm --- corpus/oracle-fp-baseline.txt | 7 +--- .../subscription-static-class-host/after.cs | 16 -------- .../subscription-static-class-host/before.cs | 14 ------- .../subscription-static-class-host/case.own | 19 ---------- .../expected-diagnostics.txt | 1 - .../subscription-static-class-host/notes.md | 38 ------------------- docs/notes/oracle-known-fps.md | 32 +++++----------- frontend/roslyn/OwnSharp.Extractor/Program.cs | 16 +++----- 8 files changed, 17 insertions(+), 126 deletions(-) delete mode 100644 corpus/real-world/subscription-static-class-host/after.cs delete mode 100644 corpus/real-world/subscription-static-class-host/before.cs delete mode 100644 corpus/real-world/subscription-static-class-host/case.own delete mode 100644 corpus/real-world/subscription-static-class-host/expected-diagnostics.txt delete mode 100644 corpus/real-world/subscription-static-class-host/notes.md diff --git a/corpus/oracle-fp-baseline.txt b/corpus/oracle-fp-baseline.txt index af1ae53e..d112123f 100644 --- a/corpus/oracle-fp-baseline.txt +++ b/corpus/oracle-fp-baseline.txt @@ -44,8 +44,5 @@ JamesNK/Newtonsoft.Json | TraceJsonReader.cs | OWN001 | _textWriter | no-op disp 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 # --- JoshClose/CsvHelper -------------------------------------------------------- -# Both ConsoleHost hooks (AppDomain.ProcessExit / Console.CancelKeyPress) were here, but -# the static-class subscriber exemption (clsIsStatic) now clears them at the source — -# ConsoleHost is a `static class`, no instance to over-promote. Confirmed cleared on a -# live CsvHelper oracle run (own-only 0, both findings absent from own-only and -# baselined) — deleted. No CsvHelper FP entries remain. +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 +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 diff --git a/corpus/real-world/subscription-static-class-host/after.cs b/corpus/real-world/subscription-static-class-host/after.cs deleted file mode 100644 index 90d83881..00000000 --- a/corpus/real-world/subscription-static-class-host/after.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; - -// FIX: the host is a STATIC class — it has no instance, so the process-lived -// subscription promotes nothing (a static class is already process-lived; its state -// lives for the whole process by definition). The shutdown hook captures only -// static/local state, never a `this`. Not a leak (mined on CsvHelper's static -// `ConsoleHost`, whose `ProcessExit`/`CancelKeyPress` hooks are false positives). -static class Host -{ - static int _count; - - public static void Attach() - { - Console.CancelKeyPress += (s, e) => _count++; // no instance to over-promote - } -} diff --git a/corpus/real-world/subscription-static-class-host/before.cs b/corpus/real-world/subscription-static-class-host/before.cs deleted file mode 100644 index a2099a37..00000000 --- a/corpus/real-world/subscription-static-class-host/before.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; - -// BUG: an INSTANCE host subscribes a `this`-capturing handler to the process-lived -// static event `Console.CancelKeyPress` and never detaches it. The process-lived -// source pins this Host instance for the whole process — an OWN014 region escape. -sealed class Host -{ - int _count; - - public Host() - { - Console.CancelKeyPress += (s, e) => _count++; // captures this; never -= - } -} diff --git a/corpus/real-world/subscription-static-class-host/case.own b/corpus/real-world/subscription-static-class-host/case.own deleted file mode 100644 index 84e71716..00000000 --- a/corpus/real-world/subscription-static-class-host/case.own +++ /dev/null @@ -1,19 +0,0 @@ -// OwnLang model of the static-class shutdown-hook subscription. before.cs subscribes a -// `this`-capturing handler to a process-lived static event (Console.CancelKeyPress) from -// an INSTANCE host and never unsubscribes — a subscription leak. The real C# lands the -// REGION form (OWN014 escape, static source outlives the instance); here it is modelled -// in the equivalent TOKEN form as the generic OWN001 (a Subscription acquire with no -// release), the shape test_corpus.py checks — exactly as the sibling -// corpus/real-world/screentogif-systemevents-leak case does. The fix makes the host a -// `static class` (no instance to promote), which the extractor's static-class subscriber -// exemption drops; see notes.md for the recognition rule. -module Corpus -resource Subscription { - acquire Subscribe - release Unsubscribe - kind "subscription token" -} -fn AttachShutdownHook(source: int) { - let token = acquire Subscription(source); // Console.CancelKeyPress += (s, e) => _count++ - // no `release token;` — the instance host never `-=`'s the process-lived source (leak) -} diff --git a/corpus/real-world/subscription-static-class-host/expected-diagnostics.txt b/corpus/real-world/subscription-static-class-host/expected-diagnostics.txt deleted file mode 100644 index ed2a1929..00000000 --- a/corpus/real-world/subscription-static-class-host/expected-diagnostics.txt +++ /dev/null @@ -1 +0,0 @@ -OWN001 diff --git a/corpus/real-world/subscription-static-class-host/notes.md b/corpus/real-world/subscription-static-class-host/notes.md deleted file mode 100644 index 2fc2c4d9..00000000 --- a/corpus/real-world/subscription-static-class-host/notes.md +++ /dev/null @@ -1,38 +0,0 @@ -# subscription-static-class-host - -A subscription to a process-lived static event (`AppDomain.ProcessExit`, -`Console.CancelKeyPress`) from inside a **`static class`** is not an OWN014 region -escape. Mined on CsvHelper's static `ConsoleHost`: - -```csharp -public static class ConsoleHost -{ - // ... - AppDomain.CurrentDomain.ProcessExit += delegate { ShutDown(); }; - Console.CancelKeyPress += (sender, eventArgs) => { ShutDown(); eventArgs.Cancel = true; }; -} -``` - -OWN014's premise is that a long-lived source **promotes the subscribing component -instance** to its lifetime (the zombie pattern). A `static class` has **no instance** — -the promotion target does not exist, and the class's own state is process-lived by the -language definition. So the finding is vacuously false. - -- **before.cs** — an **instance** `Host` subscribes a `this`-capturing handler to - `Console.CancelKeyPress` (process-lived static event), never `-=` → real `OWN014` - (the source pins the instance for the whole process). -- **after.cs** — the host is a `static class` → no instance to promote → **silent**. - -## Recognition rule - -The static-source region-escape exemption already drops a subscription whose subscriber -is the process-lived WPF `App` singleton (`IsProcessLivedApplication`) — it cannot be -over-promoted. This adds the analogous, **language-guaranteed** case: a `static class` -subscriber (`clsIsStatic`). Both gate the same `source == "static"` (process-lived -source) region escape, and both are scoped to NON-timers (a never-stopped timer is a -real leak regardless of who owns it). - -This is sound, not a heuristic: `static class` is a compile-time guarantee of no -instances. It does **not** suppress an instance class's subscription, nor a static -class's subscription to a *shorter-lived* (non-static) source — only the process-lived -source escape, where there is provably nothing to over-promote. diff --git a/docs/notes/oracle-known-fps.md b/docs/notes/oracle-known-fps.md index 45002fd9..1aa8da53 100644 --- a/docs/notes/oracle-known-fps.md +++ b/docs/notes/oracle-known-fps.md @@ -23,25 +23,17 @@ reason: we and the oracles occupy orthogonal niches. | disposition | count | what happens on re-run | |---|---:|---| -| **Fixed in the extractor** | 8 | no longer fire (5 NLog `WaitForDispose` timers + protobuf `XsltOptions` self-cycle + 2 CsvHelper static-class hooks — see below) | -| **Baselined FP** | 4 | moved to "Known FP (baselined)", out of the triage queue | +| **Fixed in the extractor** | 6 | no longer fire (5 NLog `WaitForDispose` timers + protobuf `XsltOptions` self-cycle — see below) | +| **Baselined FP** | 6 | moved to "Known FP (baselined)", out of the triage queue | | **Non-product (path filter)** | 2 | dropped by `--exclude-tests` (`unittest` rule) | | **True positive — kept visible** | 4 | stays in "Own.NET only" (real catch, oracle can't express) | | **True-but-benign — kept, baselined-as-sample** | 2 | (protobuf `assorted/` samples) baselined as non-product | -The 4 baselined FPs + the 2 non-product-sample reals = 6 findings, covered by -**5 rules** in `corpus/oracle-fp-baseline.txt` (the two `NetTranscoder` copies +The 6 baselined FPs + the 2 non-product-sample reals = 8 findings, covered by +**7 rules** in `corpus/oracle-fp-baseline.txt` (the two `NetTranscoder` copies share one basename-keyed rule); the 2 test-base findings are the `--exclude-tests` drops; the 4 true positives are deliberately **not** suppressed. -**Update (extractor fix landed — CsvHelper static-class hooks).** Both -`ConsoleHost` findings (`AppDomain.ProcessExit` / `Console.CancelKeyPress` shutdown -hooks) are now **fixed at the source** by the static-class subscriber exemption -(`clsIsStatic`): `ConsoleHost` is a `static class`, which has no instance for the -process-lived subscription to over-promote, so OWN014's premise is vacuously false -(a language guarantee, not a heuristic). See root-cause #3. Corpus fixture: -`subscription-static-class-host`. - **Update (extractor fix landed — protobuf self-cycle).** `CommandLineOptions.XsltOptions. XsltMessageEncountered` — a `this`-capturing handler subscribed to an event on `XsltOptions`, a get-only property over a constructed field the class owns — is now @@ -175,16 +167,12 @@ entry and let the oracle re-confirm clean. get-only property over a member the class constructs, is the same collectable self-cycle as the owned field directly (get-only required: a settable property could be reassigned to an injected object). Cleared protobuf `XsltOptions` on a live re-run - (own-only 0); corpus fixture `subscription-self-owned-property`. **Second shipped - fix:** CsvHelper's process-lived host is now handled by the `clsIsStatic` exemption — - a `static class` subscriber has no instance to over-promote, so a process-lived - (static-source) subscription inside one is never an OWN014 escape (language guarantee). - Cleared both `ConsoleHost` hooks; corpus fixture `subscription-static-class-host`. - **Still open:** Newtonsoft `serializer.Error` — the source is a returned `Create()` - result (escapes) and the handler is a parameter's delegate; the source's lifetime - relative to the handler is genuinely unprovable syntactically, so the "may outlive" - warning is honest (baselined, not a clear FP). A process-lived *instance* host (a - non-static singleton) still needs a lifetime signal — open. See + (own-only 0); corpus fixture `subscription-self-owned-property`. **Still open:** + Newtonsoft `serializer.Error` — the source is a returned `Create()` result (escapes) + and the handler is a parameter's delegate; the source's lifetime relative to the + handler is genuinely unprovable syntactically, so the "may outlive" warning is honest + (baselined, not a clear FP). CsvHelper's process-lived host needs a "subscriber is + itself process-lived" signal — still open. See [`subscription-leaks-and-profiles.md`](subscription-leaks-and-profiles.md). 4. **Non-product trees** *(protobuf `assorted/`, CsvHelper `docs-src/`, protobuf diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 9814a20e..6976abf4 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -3279,12 +3279,6 @@ or ImplicitObjectCreationExpressionSyntax // Is this class the process-lived WPF application object? Used to drop the // static-source region escape (OWN014) — `App` cannot be over-promoted. var clsIsApp = IsProcessLivedApplication(cls); - // A `static class` has NO instance, so a subscription in it can never promote a - // *component instance* to a longer lifetime — OWN014's premise is vacuously false - // (the class's own state is already process-lived). Sound by the language - // guarantee, not a heuristic. Mined on CsvHelper's static `ConsoleHost`, whose - // `AppDomain.ProcessExit` / `Console.CancelKeyPress` shutdown hooks are FPs. - var clsIsStatic = cls.Modifiers.Any(mod => mod.IsKind(SyntaxKind.StaticKeyword)); var subs = new List(); foreach (var a in assigns) @@ -3326,12 +3320,12 @@ or ImplicitObjectCreationExpressionSyntax : SubscriptionSourceKind(a.Left, ev, model); if (source == "local") continue; - // Process-lived subscriber: the WPF `App` singleton, OR any `static class` - // (which has no instance to over-promote). A static-source subscription - // promotes nothing, so the region escape (OWN014) is a false positive. - // Scoped to NON-timers: a timer is forced to source "static" above, but a + // Process-lived subscriber (the WPF `App` singleton): a static-source + // subscription promotes nothing — `App` already lives for the whole + // process — so the region escape (OWN014) is a false positive. Scoped + // to NON-timers: a timer is forced to source "static" above, but a // never-stopped timer in `App` is still a real leak (CodeRabbit). - if (!isTimer && source == "static" && (clsIsApp || clsIsStatic)) + if (!isTimer && source == "static" && clsIsApp) continue; var released = unsub.Contains($"{a.Left}|{a.Right}") || (isTimer && Receiver(a.Left) is { } recv && stopped.Contains(recv));