Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/oracle.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -260,6 +260,11 @@ jobs:
# Compare on product code by default: tests/benchmarks pollute the diff
# (and Infer# only built the product project). include_tests keeps them.
[[ "${INCLUDE_TESTS,,}" == "true" ]] || args+=(--exclude-tests)
# Suppress verified false positives (triaged against real source) so the
# triage queue surfaces only NEW own-only findings. Matched by name, not
# line, so it survives the target drifting at HEAD. See the file header and
# docs/notes/oracle-known-fps.md.
[[ -f corpus/oracle-fp-baseline.txt ]] && args+=(--baseline corpus/oracle-fp-baseline.txt)
[[ -f infer-out/report.sarif ]] && args+=(--infersharp infer-out/report.sarif)
cq=$(find codeql-out -name '*.sarif' -type f 2>/dev/null | head -1 || true)
[[ -n "$cq" ]] && args+=(--codeql "$cq")
Expand Down
48 changes: 48 additions & 0 deletions corpus/oracle-fp-baseline.txt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
# Cross-tool oracle — verified false-positive baseline.
#
# Each line is one own-only finding that was triaged against the target's REAL
# source (at the run commit) and confirmed NOT to be a leak. The comparator
# (scripts/oracle_compare.py --baseline) moves these out of "Own.NET only" into a
# separate "Known FP (baselined)" section, so a re-run's triage queue shows only
# genuinely-new findings. Full per-entry rationale: docs/notes/oracle-known-fps.md.
#
# Format (|-delimited, '#' starts a full-line comment, blank lines ignored):
# <owner/repo> | <file-basename> | <OWN code> | <message-substring> | <reason>
#
# The match key is (repo, basename, OWN code, message-substring) — deliberately
# NOT the line number: re-runs clone the target at its current HEAD and the lines
# drift, but the field/event/local name in the message is stable. Keep substrings
# specific enough to bind to exactly the intended finding.
#
# This file suppresses ONLY confirmed false positives. True findings stay visible
# even when benign — e.g. serilog BatchingSink._shutdownSignal (a CTS never
# Dispose()d) and NLog's XmlParser._xmlSource / FileTarget._reusable*Stream
# (fields genuinely never disposed, but wrapping managed-memory-only resources).
# Those are NOT baselined; they are real catches the oracle's leak query misses.

# --- NLog/NLog : all WaitForDispose timer FPs fixed at the source ----------------
# All five NLog timers disposed through the `WaitForDispose(this Timer)` sink are now
# cleared in the extractor — no baseline entries remain. Four (AsyncTaskTarget
# _taskTimeoutTimer/_lazyWriterTimer, AsyncTargetWrapper _lazyWriterTimer,
# BufferingTargetWrapper _flushTimer) by CallReleasesReceiver (direct / simple-alias
# receiver), and TimeoutContinuation _timeoutTimer by RefExchangeNulledField (its
# receiver is the `Interlocked.Exchange(ref _timeoutTimer, null)` result, now bound to
# the field). The three remaining NLog own-only findings (XmlParser _xmlSource,
# FileTarget _reusable*Stream) are genuine undisposed fields, kept visible.

# --- protobuf-net/protobuf-net --------------------------------------------------
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"
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
# CommandLineOptions XsltMessageEncountered was here (self-cycle: source `XsltOptions` is
# a get-only property over a constructed XsltArgumentList field, handler captures `this`).
# Now fixed at the source by PropertyReturnsOwnedMember — confirmed cleared on a live
# protobuf oracle run (own-only 0, the finding absent from own-only and baselined) — deleted.
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

# --- JamesNK/Newtonsoft.Json ----------------------------------------------------
JamesNK/Newtonsoft.Json | TraceJsonReader.cs | OWN001 | _textWriter | no-op dispose: a JsonTextWriter over an in-memory StringWriter/StringBuilder holds no unmanaged resource, so leaving it undisposed releases nothing
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
33 changes: 33 additions & 0 deletions corpus/real-world/field-dispose-via-exchange/after.cs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
using System;
using System.Threading;

// The "detach and dispose" teardown: stop + dispose the timer through the sink.
static class TimerSink
{
public static void WaitForDispose(this Timer timer, TimeSpan timeout)
{
timer.Change(Timeout.Infinite, Timeout.Infinite);
timer.Dispose();
}
}

// FIX: the owned Timer field is released through the canonical atomic teardown —
// `Interlocked.Exchange(ref _timer, null)` hands back the live timer and nulls the
// field, then the sink disposes it. Recognising this needs two hops: the local
// `current` is bound to `_timer` because Exchange returns the field's owned object
// (RefExchangeNulledField), and `current?.WaitForDispose(...)` is a release because
// the sink disposes its receiver (CallReleasesReceiver). Together → no leak, silent.
sealed class Continuation : IDisposable
{
Timer? _timer;

public Continuation() => _timer = new Timer(_ => { }, null, 0, 1000);

public void Dispose() => StopTimer();

void StopTimer()
{
var current = Interlocked.Exchange(ref _timer, null);
current?.WaitForDispose(TimeSpan.Zero);
}
}
28 changes: 28 additions & 0 deletions corpus/real-world/field-dispose-via-exchange/before.cs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
using System;
using System.Threading;

// The "detach and dispose" teardown mined on NLog's TimeoutContinuation: the owned
// Timer field is meant to be released via `Interlocked.Exchange(ref _timer, null)`
// (which hands back the live timer and nulls the field) followed by the
// `WaitForDispose(this Timer)` sink. Here the teardown is missing entirely.
static class TimerSink
{
public static void WaitForDispose(this Timer timer, TimeSpan timeout)
{
timer.Change(Timeout.Infinite, Timeout.Infinite);
timer.Dispose();
}
}

// BUG: the owned Timer field is constructed and never released on any path → OWN001.
sealed class Continuation : IDisposable
{
Timer? _timer;

public Continuation() => _timer = new Timer(_ => { }, null, 0, 1000);

public void Dispose()
{
// nothing — _timer is leaked
}
}
21 changes: 21 additions & 0 deletions corpus/real-world/field-dispose-via-exchange/case.own
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
// OwnLang model of NLog's TimeoutContinuation teardown (src/NLog/Internal/
// TimeoutContinuation.cs, StopTimer). The owned Timer field is released by the atomic
// detach-and-dispose idiom: `Interlocked.Exchange(ref _timeoutTimer, null)` hands back
// the live timer and nulls the field, then `WaitForDispose(this Timer)` stops and
// disposes it. before.cs omits the teardown — the generic OWN001 owned-field leak,
// modelled here as an acquire with no `release`. after.cs releases it via the idiom,
// which the extractor recognises by binding the exchange result to the field
// (RefExchangeNulledField) and following the sink's dispose effect (CallReleasesReceiver).
module Corpus
resource Timer {
acquire create
release dispose
kind "disposable"
emit_type "Timer"
emit_acquire "new Timer({args})"
emit_release "Interlocked.Exchange(ref {0}, null)?.WaitForDispose()"
}
fn Continuation(callback: int) {
let timer = acquire Timer(callback); // _timer = new Timer(_ => { }, null, 0, 1000)
// no `release timer;` — the timer is never detached or disposed (OWN001)
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
OWN001
34 changes: 34 additions & 0 deletions corpus/real-world/field-dispose-via-exchange/notes.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
# field-dispose-via-exchange

The atomic **detach-and-dispose** teardown for an owned `IDisposable` field, mined on
NLog's `TimeoutContinuation.StopTimer` (`src/NLog/Internal/TimeoutContinuation.cs`):

```csharp
var current = Interlocked.Exchange(ref _timeoutTimer, null);
current?.WaitForDispose(TimeSpan.Zero);
```

`Interlocked.Exchange(ref _field, null)` atomically nulls the field and **returns the
object it used to own**, so the local `current` aliases the field's just-detached
disposable; the `WaitForDispose(this Timer)` sink then stops and disposes it.

- **before.cs** — the `Timer` field is constructed and never released → `OWN001`.
- **after.cs** — released via the exchange + sink → **clean**.

## Recognition rule

Two hops, each reusing existing machinery:

1. **`RefExchangeNulledField`** binds the local `current` to `_timer`, because
`Interlocked.Exchange(ref _timer, null)` returns the field's owned object. Restricted
to a `null`/`default` replacement — the unambiguous teardown; an exchange that installs
a *new* non-null value re-arms the field with a fresh object the syntactic scan can't
follow, so crediting the field there could hide a real leak (declined, precision-first).
The bound alias joins the same `aliasToField` map as a plain `var x = _field;`.
2. **`CallReleasesReceiver`** recognises `current?.WaitForDispose(...)` as a release,
because the first-party extension sink disposes its receiver (proved via `ConsumesParam`).

Together they clear `TimeoutContinuation`, the last NLog timer that the
[`field-dispose-via-helper`](../field-dispose-via-helper/notes.md) fix did not reach
(its receiver was the exchange result, not a tracked field alias). With this, all five
NLog `WaitForDispose` timer false positives are fixed at the source.
27 changes: 27 additions & 0 deletions corpus/real-world/field-dispose-via-helper/after.cs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
using System;
using System.Threading;

// A "drain and dispose" sink: stops the timer, then disposes it. Because its
// receiver parameter is disposed in the body, a `_timer.WaitForDispose(...)` call
// releases the field — the extractor proves this by inspecting the sink's body
// (ConsumesParam on the reduced extension method's receiver), not by name.
static class TimerSink
{
public static void WaitForDispose(this Timer timer, TimeSpan timeout)
{
timer.Change(Timeout.Infinite, Timeout.Infinite);
timer.Dispose();
}
}

// FIX: the owned Timer field is released through the sink on Dispose. No literal
// `_timer.Dispose()` appears, so recognising this requires following the sink's
// dispose effect — once we do, there is no leak and the case is silent.
sealed class Worker : IDisposable
{
readonly Timer _timer;

public Worker() => _timer = new Timer(_ => { }, null, 0, 1000);

public void Dispose() => _timer.WaitForDispose(TimeSpan.Zero);
}
29 changes: 29 additions & 0 deletions corpus/real-world/field-dispose-via-helper/before.cs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
using System;
using System.Threading;

// A "drain and dispose" sink: the canonical shape mined on NLog, where a Timer
// field is released not by a literal `_timer.Dispose()` but by a custom extension
// method that stops the timer and then disposes it (NLog's
// `WaitForDispose(this Timer, TimeSpan)` in Common/AsyncHelpers.cs).
static class TimerSink
{
public static void WaitForDispose(this Timer timer, TimeSpan timeout)
{
timer.Change(Timeout.Infinite, Timeout.Infinite);
timer.Dispose();
}
}

// BUG: the owned Timer field is constructed but never released on any path —
// neither a literal `.Dispose()` nor the sink. A genuine OWN001 owned-field leak.
sealed class Worker : IDisposable
{
readonly Timer _timer;

public Worker() => _timer = new Timer(_ => { }, null, 0, 1000);

public void Dispose()
{
// nothing — _timer is leaked
}
}
23 changes: 23 additions & 0 deletions corpus/real-world/field-dispose-via-helper/case.own
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
// OwnLang model of the "drain and dispose" field-release pattern mined on NLog
// (src/NLog/Targets/AsyncTaskTarget.cs and siblings). A Timer field is owned by the
// component; the real fix releases it not with a literal `_timer.Dispose()` but
// through a custom extension method `WaitForDispose(this Timer, TimeSpan)` that
// stops the timer and then disposes it (Common/AsyncHelpers.cs). The before.cs
// leaks the timer (no release on any path) — the generic OWN001 owned-field leak
// modelled here as an acquire with no `release`. The after.cs releases it via the
// sink, which the extractor now recognises by inspecting the sink's body
// (ConsumesParam on its receiver parameter); see notes.md for that recognition rule
// and which NLog variants it does and does not reach.
module Corpus
resource Timer {
acquire create
release dispose
kind "disposable"
emit_type "Timer"
emit_acquire "new Timer({args})"
emit_release "{0}.WaitForDispose()"
}
fn Worker(callback: int) {
let timer = acquire Timer(callback); // _timer = new Timer(_ => { }, null, 0, 1000)
// no `release timer;` — the timer is never stopped or disposed (OWN001)
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
OWN001
41 changes: 41 additions & 0 deletions corpus/real-world/field-dispose-via-helper/notes.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
# field-dispose-via-helper

An owned `IDisposable` field is released not by a literal `_field.Dispose()` but by
a **first-party extension method that disposes its receiver** — the "drain and
dispose" sink. The canonical real-world shape is NLog's
`WaitForDispose(this Timer, TimeSpan)` (`Common/AsyncHelpers.cs`), which stops the
timer (`Change(Infinite, Infinite)`) and then disposes it; targets call
`_taskTimeoutTimer.WaitForDispose(...)` from `Dispose(bool disposing)` /
`CloseTarget()`.

- **before.cs** — the `Timer` field is constructed and never released on any path
→ `OWN001` (the bug is caught).
- **after.cs** — the field is released via `_timer.WaitForDispose(...)` → **clean**.
No literal `.Dispose()` on the field appears, so the only way to see the release
is to follow the sink's dispose effect.

## Recognition rule

The disposal scan already credits a field released anywhere in the class by
`field.Dispose()` / `.Close()` / `.DisposeAsync()` (directly, through a
`var t = _field;` alias, or null-conditional), so a field disposed in
`Dispose(bool disposing)` or `CloseTarget()` is already handled. This case adds the
missing hop: a call `field.M(...)` also releases the field when **`M` is a
first-party extension method whose receiver it disposes**. It is proved, not
guessed — `CallReleasesReceiver` reuses `ConsumesParam` on the reduced extension
method's receiver parameter (index 0), which inspects `M`'s real body, follows
first-party forwarding chains, is cycle-guarded, and requires an `IDisposable`
parameter. So an unknown or borrowing callee never credits a release.

## Honesty caveat — what this does and does not reach

This clears the NLog variants where the sink is called on the field **directly** or
through a simple `var t = _field;` alias (`AsyncTaskTarget`, `AsyncTargetWrapper`,
`BufferingTargetWrapper`). The `TimeoutContinuation` variant — where the receiver is
the result of `Interlocked.Exchange(ref _timer, null)`, the detach-and-dispose
teardown — is covered separately by the sibling
[`field-dispose-via-exchange`](../field-dispose-via-exchange/notes.md) fixture
(`RefExchangeNulledField` binds the exchange result to the field, then this same sink
recognition applies). Scope is intentionally limited to **extension methods**: an
instance method disposing its own `this` is not a real dispose-delegation shape and
would drag in virtual-dispatch reasoning.
20 changes: 20 additions & 0 deletions corpus/real-world/subscription-self-owned-property/after.cs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
using System;

class Bus { public event EventHandler? Changed; }

// FIX: the watcher now OWNS the bus — a get-only property over a field it constructs.
// The subscription is therefore a self-cycle: the Watcher, the owned Bus, and the
// handler form one object graph the GC collects together. Not a leak, even with no
// `-=` (mined on protobuf-net's CommandLineOptions, where `XsltOptions` is a get-only
// property over a constructed XsltArgumentList field and the handler captures `this`).
sealed class Watcher
{
readonly Bus _bus = new Bus(); // constructed — owned by this
Bus Channel => _bus; // get-only property returning the owned field
int _count;

public Watcher()
{
Channel.Changed += (s, e) => _count++; // self-owned source -> silent
}
}
19 changes: 19 additions & 0 deletions corpus/real-world/subscription-self-owned-property/before.cs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
using System;

class Bus { public event EventHandler? Changed; }

// BUG: the watcher subscribes a `this`-capturing handler to an INJECTED bus — an
// external object of unknown, potentially longer lifetime — and never detaches it.
// The bus's handler list keeps the Watcher alive for the bus's lifetime: a real
// subscription leak (the source may outlive `this`).
sealed class Watcher
{
readonly Bus _bus; // injected — lifetime owned by someone else
int _count;

public Watcher(Bus bus)
{
_bus = bus;
_bus.Changed += (s, e) => _count++; // never -= : leak
}
}
19 changes: 19 additions & 0 deletions corpus/real-world/subscription-self-owned-property/case.own
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
// OwnLang model of the self-owned-property subscription discrimination, mined on
// protobuf-net's CommandLineOptions: a `this`-capturing handler is subscribed to an
// event on a source the component OWNS (a get-only property over a constructed field),
// which is a collectable self-cycle, NOT a leak. before.cs is the leaky counterpart —
// the same subscription on an INJECTED bus (external lifetime), never detached — the
// generic OWN001 subscription leak, modelled here as a Subscription acquire with no
// release. The extractor tells the two apart by resolving the event source: an owned
// member (this/field/get-only-owned-property) is dropped (self-cycle), an injected one
// stays a warning. See notes.md for the recognition rule (PropertyReturnsOwnedMember).
module Corpus
resource Subscription {
acquire Subscribe
release Unsubscribe
kind "subscription token"
}
fn Watch(bus: int) {
let token = acquire Subscription(bus); // _bus.Changed += (s, e) => _count++
// no `release token;` — the injected bus is never `-=`'d -> handler leak (OWN001)
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
OWN001
Loading
Loading