Summary
The subscription-leak detector over-reports when an event is subscribed on a publisher that is itself a freshly-created object the method's caller news and returns. The subscription is bounded by the returned publisher's lifetime (it dies with it), so no -= is needed — but the extractor can't see that, because the construct-and-return happens in the caller.
This is field-notes entry #7 ("Event subscription on a freshly-created, returned publisher") promoted to a live tracker, following the oracle run on JamesNK/Newtonsoft.Json (see docs/notes/field-notes-patterns.md).
Worked example (Newtonsoft.Json Src/Newtonsoft.Json/JsonSerializer.cs:717, commit 4f73e74)
public static JsonSerializer Create(JsonSerializerSettings? settings)
{
JsonSerializer serializer = new JsonSerializer();
if (settings != null) ApplySerializerSettings(serializer, settings);
return serializer; // publisher escapes to the caller
}
private static void ApplySerializerSettings(JsonSerializer serializer, JsonSerializerSettings settings)
{
if (settings.Error != null)
serializer.Error += settings.Error; // flagged: "+= but never -="
}
The publisher (serializer) is the returned object: the handler lives exactly as long as the serializer the caller now holds, and dies with it. Not a leak. CodeQL/Infer# have no event-subscription query, so they stay silent (correctly) — this is an own-only over-report.
Why it is not a safe local fix
Inside ApplySerializerSettings, serializer is an opaque parameter, so SubscriptionSourceKind (frontend/roslyn/OwnSharp.Extractor/Program.cs) conservatively tiers it injected and emits a warning (not a hard error — P-004 severity tiering already hedges). Blanket-suppressing a parameter publisher would lose real leaks: void Wire(EventBus bus, Sub s){ bus.X += s.OnX; } is the same syntax (param→param), but if bus is a DI singleton it's a genuine subscription leak. The two are indistinguishable within the method — proving "bounded" requires interprocedural publisher provenance.
Proposed approach
This is the dual of ownership transfer: a += on a publisher that escapes by return is as bounded as a returned IDisposable. The fix should reuse the D5 escape/summary machinery (see docs/notes/d5-ownership-transfer.md):
- Compute, per first-party method, whether a parameter is the constructed-and-returned value of its callers (publisher provenance) — i.e. the publisher's lifetime is caller-owned.
- When a subscription's publisher resolves to such a "returned-fresh" provenance, lower it to the bounded/
local tier (silent), not injected.
- Precision-first: any uncertainty stays
injected (advisory warning), never silently dropped, and the DI-singleton real-leak case is untouched.
Current posture (acceptable interim)
The finding already surfaces only as an advisory warning under --severity warning (the oracle's setting); the default error posture is unaffected and CI does not break. So this is a recall/precision-of-warnings refinement, not a correctness blocker.
Revisit trigger
Land alongside (or after) the D5 interprocedural escape/return-skeleton work that can supply parameter→returned-publisher provenance. Until then the conservative injected warning is the honest posture.
Prior art
Checker Framework @MustCallAlias (one obligation, two handles), RLC ownership-transfer defaults; cf. issue #122 (D5.1c) for the same "defer + track an interprocedural axis" pattern.
Origin: oracle run on Newtonsoft.Json; triaged in field-notes-patterns.md #7.
Summary
The subscription-leak detector over-reports when an event is subscribed on a publisher that is itself a freshly-created object the method's caller
news and returns. The subscription is bounded by the returned publisher's lifetime (it dies with it), so no-=is needed — but the extractor can't see that, because the construct-and-return happens in the caller.This is field-notes entry #7 ("Event subscription on a freshly-created, returned publisher") promoted to a live tracker, following the oracle run on
JamesNK/Newtonsoft.Json(seedocs/notes/field-notes-patterns.md).Worked example (Newtonsoft.Json
Src/Newtonsoft.Json/JsonSerializer.cs:717, commit4f73e74)The publisher (
serializer) is the returned object: the handler lives exactly as long as the serializer the caller now holds, and dies with it. Not a leak. CodeQL/Infer# have no event-subscription query, so they stay silent (correctly) — this is an own-only over-report.Why it is not a safe local fix
Inside
ApplySerializerSettings,serializeris an opaque parameter, soSubscriptionSourceKind(frontend/roslyn/OwnSharp.Extractor/Program.cs) conservatively tiers itinjectedand emits a warning (not a hard error — P-004 severity tiering already hedges). Blanket-suppressing a parameter publisher would lose real leaks:void Wire(EventBus bus, Sub s){ bus.X += s.OnX; }is the same syntax (param→param), but ifbusis a DI singleton it's a genuine subscription leak. The two are indistinguishable within the method — proving "bounded" requires interprocedural publisher provenance.Proposed approach
This is the dual of ownership transfer: a
+=on a publisher that escapes by return is as bounded as a returnedIDisposable. The fix should reuse the D5 escape/summary machinery (seedocs/notes/d5-ownership-transfer.md):localtier (silent), notinjected.injected(advisory warning), never silently dropped, and the DI-singleton real-leak case is untouched.Current posture (acceptable interim)
The finding already surfaces only as an advisory warning under
--severity warning(the oracle's setting); the defaulterrorposture is unaffected and CI does not break. So this is a recall/precision-of-warnings refinement, not a correctness blocker.Revisit trigger
Land alongside (or after) the D5 interprocedural escape/return-skeleton work that can supply parameter→returned-publisher provenance. Until then the conservative
injectedwarning is the honest posture.Prior art
Checker Framework
@MustCallAlias(one obligation, two handles), RLC ownership-transfer defaults; cf. issue #122 (D5.1c) for the same "defer + track an interprocedural axis" pattern.Origin: oracle run on Newtonsoft.Json; triaged in
field-notes-patterns.md#7.