Skip to content

Commit 3359edf

Browse files
committed
extractor: self-owned-property is a collectable subscription source
A subscription whose event SOURCE is reached through a get-only property the class owns — this.Child.Event += this-capturing-handler, where Child is a property over a constructed field — is the same collectable self-cycle as subscribing on the owned field directly, not a leak. The self-owned exemption already covered this/fields/ locals the class constructs but not the PROPERTY receiver, so protobuf-net's CommandLineOptions (XsltOptions, a get-only property over a constructed XsltArgumentList, with a this-capturing handler) was falsely warned as an injected subscription leak. PropertyReturnsOwnedMember recognises a get-only property whose value the class owns: an auto-property '{ get; } = new T()', or a getter returning a constructed member (=> _owned / get => _owned / get { return _owned; }). Get-only is required — a settable property could be reassigned to an injected, longer-lived object, which we cannot prove bounded, so it falls through to the honest 'injected' warning (precision-first: never silently drop a real leak). New corpus fixture subscription-self-owned-property: before.cs subscribes to an injected bus (real OWN001 leak, caught), after.cs to a self-owned property (silent). Newtonsoft's serializer.Error (serializer = Create() that escapes, handler is a param's delegate) stays baselined: proving it non-leak needs real lifetime modelling, so the 'may outlive' warning is honest, not a clear FP. Re-pointing the oracle at protobuf to confirm the XsltOptions FP clears. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019846YSZ35c7CdkWQ1qX5gm
1 parent 964369c commit 3359edf

9 files changed

Lines changed: 153 additions & 4 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: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@
3333
# --- protobuf-net/protobuf-net --------------------------------------------------
3434
protobuf-net/protobuf-net | ProtoWriter.BufferWriter.cs | OWN001 | _nullWriter | intentional null-object kept attached for pooled reuse; Dispose() documents "don't cascade dispose to the null one"
3535
protobuf-net/protobuf-net | ProtoTranscoder.cs | OWN001 | 'sync' | non-product (protobuf 'assorted' sample/extension tree); NetTranscoder is a long-lived singleton holding one ReaderWriterLockSlim for app lifetime
36-
protobuf-net/protobuf-net | CommandLineOptions.cs | OWN001 | XsltMessageEncountered | non-product (assorted/ProtoGen) + self-subscription: publisher (xsltOptions) and the lambda subscriber are both owned by the same CommandLineOptions, co-lifetimed
36+
# [fix-pending experiment] CommandLineOptions XsltMessageEncountered is cleared by the
37+
# PropertyReturnsOwnedMember fix — the source `XsltOptions` is a get-only property over a
38+
# constructed field, a self-cycle. Disabled here to confirm on a live protobuf oracle run
39+
# that it no longer fires; if confirmed it is deleted, if it resurfaces it is restored.
40+
# protobuf-net/protobuf-net | CommandLineOptions.cs | OWN001 | XsltMessageEncountered | non-product (assorted/ProtoGen) + self-subscription: publisher (xsltOptions) and the lambda subscriber are both owned by the same CommandLineOptions, co-lifetimed
3741
protobuf-net/protobuf-net | Page.xaml.cs | OWN001 | local 'timer' | non-product (assorted/ Silverlight sample); the timer is disposed by an enclosing `using (timer) { ... }` the extractor missed
3842

3943
# --- JamesNK/Newtonsoft.Json ----------------------------------------------------

‎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 protobuf-net to confirm PropertyReturnsOwnedMember clears the
17+
# CommandLineOptions XsltMessageEncountered self-cycle FP; restored after the run.
18+
protobuf-net/protobuf-net
19+
include_tests=false
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using System;
2+
3+
class Bus { public event EventHandler? Changed; }
4+
5+
// FIX: the watcher now OWNS the bus — a get-only property over a field it constructs.
6+
// The subscription is therefore a self-cycle: the Watcher, the owned Bus, and the
7+
// handler form one object graph the GC collects together. Not a leak, even with no
8+
// `-=` (mined on protobuf-net's CommandLineOptions, where `XsltOptions` is a get-only
9+
// property over a constructed XsltArgumentList field and the handler captures `this`).
10+
sealed class Watcher
11+
{
12+
readonly Bus _bus = new Bus(); // constructed — owned by this
13+
Bus Channel => _bus; // get-only property returning the owned field
14+
int _count;
15+
16+
public Watcher()
17+
{
18+
Channel.Changed += (s, e) => _count++; // self-owned source -> silent
19+
}
20+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using System;
2+
3+
class Bus { public event EventHandler? Changed; }
4+
5+
// BUG: the watcher subscribes a `this`-capturing handler to an INJECTED bus — an
6+
// external object of unknown, potentially longer lifetime — and never detaches it.
7+
// The bus's handler list keeps the Watcher alive for the bus's lifetime: a real
8+
// subscription leak (the source may outlive `this`).
9+
sealed class Watcher
10+
{
11+
readonly Bus _bus; // injected — lifetime owned by someone else
12+
int _count;
13+
14+
public Watcher(Bus bus)
15+
{
16+
_bus = bus;
17+
_bus.Changed += (s, e) => _count++; // never -= : leak
18+
}
19+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// OwnLang model of the self-owned-property subscription discrimination, mined on
2+
// protobuf-net's CommandLineOptions: a `this`-capturing handler is subscribed to an
3+
// event on a source the component OWNS (a get-only property over a constructed field),
4+
// which is a collectable self-cycle, NOT a leak. before.cs is the leaky counterpart —
5+
// the same subscription on an INJECTED bus (external lifetime), never detached — the
6+
// generic OWN001 subscription leak, modelled here as a Subscription acquire with no
7+
// release. The extractor tells the two apart by resolving the event source: an owned
8+
// member (this/field/get-only-owned-property) is dropped (self-cycle), an injected one
9+
// stays a warning. See notes.md for the recognition rule (PropertyReturnsOwnedMember).
10+
module Corpus
11+
resource Subscription {
12+
acquire Subscribe
13+
release Unsubscribe
14+
kind "subscription token"
15+
}
16+
fn Watch(bus: int) {
17+
let token = acquire Subscription(bus); // _bus.Changed += (s, e) => _count++
18+
// no `release token;` — the injected bus is never `-=`'d -> handler leak (OWN001)
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-self-owned-property
2+
3+
An event subscription whose SOURCE is a member the component **owns** — accessed
4+
through a get-only **property** — is a collectable self-cycle, not a leak. Mined on
5+
protobuf-net's `CommandLineOptions`:
6+
7+
```csharp
8+
private readonly XsltArgumentList xsltOptions = new XsltArgumentList();
9+
public XsltArgumentList XsltOptions => xsltOptions; // get-only, owned
10+
...
11+
XsltOptions.XsltMessageEncountered += delegate { messageCount++; }; // handler captures `this`
12+
```
13+
14+
The owned `XsltArgumentList`, the `CommandLineOptions` instance, and the handler form
15+
one object graph the GC collects together — no `-=` needed.
16+
17+
- **before.cs** — the same subscription on an **injected** `Bus` (external, unknown
18+
lifetime), never detached → a real subscription leak (`OWN001`, warning: the source
19+
may outlive `this`).
20+
- **after.cs** — the source is now a **get-only property over a constructed field** the
21+
component owns → self-cycle → **silent**.
22+
23+
## Recognition rule
24+
25+
The self-owned-source exemption already drops a subscription whose source is `this`, or
26+
a field/local the class constructs (`owned`). This case adds the missing receiver shape:
27+
a `this`-instance **get-only property** whose value the class owns —
28+
`PropertyReturnsOwnedMember`:
29+
30+
- an auto-property `public T X { get; } = new T();` (value constructed in place), or
31+
- a getter that returns a constructed member: `=> _owned`, `get => _owned`, or
32+
`get { return _owned; }` where the returned field/property is in `owned`.
33+
34+
**Get-only is required.** A settable property could be reassigned to an injected,
35+
longer-lived object after construction, which we cannot prove bounded — so a property
36+
with any setter falls through to the honest "injected" warning (precision-first: never
37+
silently drop a real leak). Computed getters and getters returning a parameter/injected
38+
field likewise fall through.

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

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,52 @@ static bool IsSelfOwnedSource(ExpressionSyntax left, IEventSymbol ev,
486486
if (m.Expression is ThisExpressionSyntax)
487487
return true;
488488
var recv = model.GetSymbolInfo(m.Expression).Symbol;
489-
return (recv is IFieldSymbol or ILocalSymbol) && owned.Contains(recv.Name);
489+
if ((recv is IFieldSymbol or ILocalSymbol) && owned.Contains(recv.Name))
490+
return true;
491+
// A `this`-instance get-only PROPERTY whose value the class owns (`this.Child.Event
492+
// += h`, Child a `=> _owned` / `{ get; } = new()` property) is the SAME collectable
493+
// self-cycle as the owned field directly — this, the owned child, and the handler
494+
// are collected together. The property must resolve to a member the class constructs.
495+
return recv is IPropertySymbol { IsStatic: false } p
496+
&& PropertyReturnsOwnedMember(p, owned, model);
497+
}
498+
499+
// A GET-ONLY property whose value the class OWNS: an auto-property initialised to
500+
// `new X()`, or one whose getter returns a field/property the class constructs
501+
// (`=> _owned`, `get => _owned`, `get { return _owned; }`). Such a property is part of
502+
// `this`'s own object graph, so a subscription on its event is the same collectable
503+
// self-cycle as subscribing on the owned field. GET-ONLY is required: a settable
504+
// property could be reassigned to an INJECTED, longer-lived object after construction,
505+
// which we cannot prove bounded — so we decline it (precision-first: never silently
506+
// drop a real leak). Only the dominant owned-property shapes are recognised; anything
507+
// else (a computed getter, a getter returning a parameter/injected field) falls through.
508+
static bool PropertyReturnsOwnedMember(IPropertySymbol prop, HashSet<string> owned,
509+
SemanticModel model)
510+
{
511+
if (prop.SetMethod is not null)
512+
return false;
513+
foreach (var sref in prop.DeclaringSyntaxReferences)
514+
{
515+
if (sref.GetSyntax() is not PropertyDeclarationSyntax pd)
516+
continue;
517+
// auto-property `public T X { get; } = new T();` — the value is constructed here.
518+
if (pd.Initializer?.Value is ObjectCreationExpressionSyntax
519+
or ImplicitObjectCreationExpressionSyntax)
520+
return true;
521+
// `=> expr`, `get => expr`, or `get { return expr; }` returning an owned member.
522+
var get = pd.AccessorList?.Accessors
523+
.FirstOrDefault(ac => ac.IsKind(SyntaxKind.GetAccessorDeclaration));
524+
var returned = pd.ExpressionBody?.Expression
525+
?? get?.ExpressionBody?.Expression
526+
?? get?.Body?.Statements.OfType<ReturnStatementSyntax>().FirstOrDefault()?.Expression;
527+
if (returned is null)
528+
continue;
529+
var pm = model.Compilation.GetSemanticModel(pd.SyntaxTree);
530+
var rsym = pm.GetSymbolInfo(returned).Symbol;
531+
if (rsym is IFieldSymbol or IPropertySymbol && owned.Contains(rsym.Name))
532+
return true;
533+
}
534+
return false;
490535
}
491536

492537
// P-004 (ext): a control fetching one of its OWN template parts —

0 commit comments

Comments
 (0)