Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ jobs:
PYTHONPATH=. python3 report/tests/test_baseline.py
PYTHONPATH=. python3 arch/tests/test_arch.py
PYTHONPATH=. python3 oracle/fixtures/test_oracle_arch.py
PYTHONPATH=. python3 oracle/fixtures/test_oracle_runtime.py
PYTHONPATH=. python3 runtime/tests/test_runtime.py

- name: re-run under -O (assert-stripping safety)
Expand All @@ -50,6 +51,7 @@ jobs:
PYTHONPATH=. python3 -O report/tests/test_baseline.py
PYTHONPATH=. python3 -O arch/tests/test_arch.py
PYTHONPATH=. python3 -O oracle/fixtures/test_oracle_arch.py
PYTHONPATH=. python3 -O oracle/fixtures/test_oracle_runtime.py
PYTHONPATH=. python3 -O runtime/tests/test_runtime.py

audit-report:
Expand Down
42 changes: 22 additions & 20 deletions oracle/LeakyOracle/LeakScenario.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,37 +7,39 @@ namespace LeakyOracle;

/// <summary>
/// A headless, self-validating proof that the oracle leaks — no display, so it runs in CI. It mimics
/// the scenario the runtime audit drives ("open and close a screen N times") for BOTH view-models:
/// the scenario the runtime audit drives ("open and close a screen N times") for TWO independent leak
/// kinds, each with its corrected counterpart run through the SAME WeakReference harness:
///
/// • the leaky <see cref="WatchlistViewModel"/> (subscribes, never detaches) must ALL survive GC;
/// • the corrected <see cref="FixedWatchlistViewModel"/> (detaches on Dispose) must ALL be collected.
/// • subscription leak — leaky <see cref="WatchlistViewModel"/> (subscribes, never detaches) must ALL
/// survive GC; <see cref="FixedWatchlistViewModel"/> (detaches on Dispose) must ALL be collected;
/// • timer leak — leaky <see cref="TickerViewModel"/> (undisposed Timer) must ALL survive GC;
/// <see cref="FixedTickerViewModel"/> (disposes the Timer) must ALL be collected.
///
/// Checking both with the same WeakReference harness proves the harness isn't rigged: if it were,
/// the fixed batch would look alive too. Exit code 0 means the oracle leaked exactly where it should
/// and nowhere it shouldn't — this is a TARGET, not a test of our auditor; if it stops behaving we
/// fail loudly because the oracle (not the auditor) is broken.
/// Checking the fixed batches too proves the harness isn't rigged: if it were, they'd look alive too.
/// Exit 0 means the oracle leaked exactly where it should and nowhere it shouldn't — this is a TARGET,
/// not a test of our auditor; if it stops behaving we fail loudly because the oracle is broken.
/// </summary>
public static class LeakScenario
{
public static int Run(int screens = 50)
{
var service = new MarketDataService();

var leakedAlive = OpenAndDrop(screens, () => new WatchlistViewModel(service));
var fixedAlive = OpenAndDrop(screens, () =>
{
var vm = new FixedWatchlistViewModel(service);
return vm;
}, dispose: vm => ((FixedWatchlistViewModel)vm).Dispose());
var subLeaked = OpenAndDrop(screens, () => new WatchlistViewModel(service));
var subFixed = OpenAndDrop(screens, () => new FixedWatchlistViewModel(service),
dispose: vm => ((FixedWatchlistViewModel)vm).Dispose());
var timerLeaked = OpenAndDrop(screens, () => new TickerViewModel());
var timerFixed = OpenAndDrop(screens, () => new FixedTickerViewModel(),
dispose: vm => ((FixedTickerViewModel)vm).Dispose());

var leaksWhereItShould = leakedAlive == screens;
var cleanWhereItShould = fixedAlive == 0;
var ok = leaksWhereItShould && cleanWhereItShould;
var ok = subLeaked == screens && subFixed == 0 && timerLeaked == screens && timerFixed == 0;

Console.WriteLine($"screens opened+closed : {screens}");
Console.WriteLine($"leaky still alive (GC) : {leakedAlive,3} (expect {screens} — rooted by MarketDataService.QuoteReceived)");
Console.WriteLine($"fixed still alive (GC) : {fixedAlive,3} (expect 0 — detached on Dispose)");
Console.WriteLine($"verdict : {(ok ? "LEAK confirmed and isolated to the un-detached subscription" : "UNEXPECTED")}");
Console.WriteLine($"screens opened+closed : {screens}");
Console.WriteLine($"subscription leaky alive (GC): {subLeaked,3} (expect {screens} — rooted by MarketDataService.QuoteReceived)");
Console.WriteLine($"subscription fixed alive (GC): {subFixed,3} (expect 0 — detached on Dispose)");
Console.WriteLine($"timer leaky alive (GC): {timerLeaked,3} (expect {screens} — rooted by the TimerQueue)");
Console.WriteLine($"timer fixed alive (GC): {timerFixed,3} (expect 0 — Timer disposed)");
Console.WriteLine($"verdict : {(ok ? "BOTH leaks confirmed, each isolated to its un-released resource" : "UNEXPECTED")}");
Console.WriteLine(ok
? "ORACLE OK: leaks as designed — a valid target for the heap/lifetime audit."
: "ORACLE BROKEN: leak signature is wrong; fix the oracle, not the auditor.");
Expand Down
25 changes: 25 additions & 0 deletions oracle/LeakyOracle/ViewModels/FixedTickerViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System;
using System.Threading;

namespace LeakyOracle.ViewModels;

/// <summary>
/// The corrected counterpart of <see cref="TickerViewModel"/>: it owns the <see cref="Timer"/> and
/// disposes it. Disposing unlinks the timer from the runtime's TimerQueue, so once disposed this
/// view-model is no longer rooted and collects normally — the control case that keeps the timer-leak
/// proof honest (same WeakReference harness; correct code collects, leaky code doesn't).
/// </summary>
public sealed class FixedTickerViewModel : IDisposable
{
private readonly Timer _timer;
private int _ticks;

public FixedTickerViewModel()
{
_timer = new Timer(OnTick, null, 300_000, 300_000);
}

private void OnTick(object? state) => _ticks++;

public void Dispose() => _timer.Dispose(); // the fix: unlink from the TimerQueue
}
31 changes: 31 additions & 0 deletions oracle/LeakyOracle/ViewModels/TickerViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Threading;

namespace LeakyOracle.ViewModels;

/// <summary>
/// A per-screen view-model that drives a live "ticker" off a <see cref="Timer"/>. DELIBERATELY LEAKY:
/// it starts a recurring <see cref="System.Threading.Timer"/> in the constructor and never disposes it.
///
/// An active timer is registered in the runtime's (static) TimerQueue, which holds the timer's callback
/// delegate — and the delegate's target is this view-model. So every TickerViewModel ever created stays
/// rooted by the timer queue until <see cref="Timer.Dispose()"/> is called: the timer-lifetime leak
/// (own-check small rule; docs/wpf-audit-coverage.md, "Timers": DispatcherTimer/Timers.Timer/
/// Threading.Timer with no Stop/Dispose). The framework-agnostic core: identical on WPF and Avalonia.
///
/// The `_timer` field is deliberate — without it the public Timer wrapper would be finalized and stop
/// the timer; holding it keeps the timer alive (and the leak real), exactly as leaky code does.
/// </summary>
public sealed class TickerViewModel

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the oracle graph for ticker view models

I checked oracle/fixtures/graph.json and oracle/fixtures/test_oracle_arch.py: the golden architecture contract still describes 8 internal types and has no TickerViewModel/FixedTickerViewModel nodes or LeakScenario edges to them. With this new view-model in the oracle, a real Roslyn extraction over oracle/LeakyOracle would now emit those two types, so the pinned graph is no longer faithful and the drift guard keeps passing the stale fixture instead of catching the mismatch.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct on both counts — fixed in 777f82b. Adding the timer VMs in (а) left the arch golden at 8 internal types, and the old drift guard ("every node points at a real file") structurally couldn't catch a missing type. Fix: added TickerViewModel/FixedTickerViewModel nodes + their edges (LeakScenario → Ticker VMs, Ticker → System.Threading.Timer, FixedTicker → IDisposable), and replaced the guard with a both-directions check — the graph's internal node set must equal the set of oracle source types (glob of LeakyOracle/**/*.cs), so a new view-model with no node (or a node for a deleted type) now fails the test. Still 0 findings on the clean graph; 4/4 normal + -O.


Generated by Claude Code

{
private readonly Timer _timer;
private int _ticks;

public TickerViewModel()
{
// recurring + far-future due time: registered (so it roots `this`) but won't actually fire
// during a sub-second scenario. Never disposed -> never unlinked from the TimerQueue.
_timer = new Timer(OnTick, null, 300_000, 300_000);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private void OnTick(object? state) => _ticks++;
}
23 changes: 13 additions & 10 deletions oracle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ and deliberately absent here (`docs/wpf-audit-coverage.md`, "Avalonia mappabilit
| Smell | Where | Rule it exercises |
|---|---|---|
| **Subscription leak** — `+=` to an app-scoped service, never `-=` | `ViewModels/WatchlistViewModel.cs` | OWN001 (event lifetime) + phase-5 heap confirm |
| **Timer leak** — undisposed recurring `System.Threading.Timer` (rooted by the TimerQueue) | `ViewModels/TickerViewModel.cs` | OWN-TIMER (timer no Stop/Dispose) + phase-5 heap confirm |
| **Duplicated strings** — `new string(...)` per row, identical content | `ViewModels/WatchlistViewModel.cs` | string-canonicalization (`docs/string-canonicalization.md`) |
| **Virtualization killed** — `ListBox` `ItemsPanel` swapped to a plain `StackPanel` | `Views/MainWindow.axaml` | XAML107 (`VirtualizationExplicitlyDisabled`) + heap confirm |

`ViewModels/FixedWatchlistViewModel.cs` is the corrected counterpart (detaches on `Dispose`) — the
control case that keeps the leak proof honest and gives the fix-arm a before/after target.
`ViewModels/Fixed*.cs` are the corrected counterparts (detach on `Dispose` / dispose the timer) — the
control cases that keep the leak proof honest and give the fix-arm a before/after target.

## Build & run

Expand All @@ -48,14 +49,16 @@ dotnet run -c Release
### What the leak proof shows

```
screens opened+closed : 50
leaky still alive (GC) : 50 (expect 50 — rooted by MarketDataService.QuoteReceived)
fixed still alive (GC) : 0 (expect 0 — detached on Dispose)
verdict : LEAK confirmed and isolated to the un-detached subscription
screens opened+closed : 50
subscription leaky alive (GC): 50 (expect 50 — rooted by MarketDataService.QuoteReceived)
subscription fixed alive (GC): 0 (expect 0 — detached on Dispose)
timer leaky alive (GC): 50 (expect 50 — rooted by the TimerQueue)
timer fixed alive (GC): 0 (expect 0 — Timer disposed)
verdict : BOTH leaks confirmed, each isolated to its un-released resource
ORACLE OK: leaks as designed — a valid target for the heap/lifetime audit.
```

Both view-models go through the **same** WeakReference harness: the leaky one survives a full GC
(rooted by the service event), the fixed one is collected. Checking both proves the harness isn't
rigged — a correct app collects, the oracle does not. If it ever stops leaking, the proof fails loudly
(exit 1): the oracle is broken, not the auditor.
Each leak's leaky and fixed view-models go through the **same** WeakReference harness: the leaky ones
survive a full GC (rooted by the service event / the TimerQueue), the fixed ones are collected.
Checking the fixed batches proves the harness isn't rigged — a correct app collects, the oracle does
not. If it ever stops leaking, the proof fails loudly (exit 1): the oracle is broken, not the auditor.
26 changes: 25 additions & 1 deletion oracle/fixtures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,37 @@ back to a view (`WatchlistViewModel → MainWindow`) — lights up three distinc
The test keeps the degraded graph in-memory (clean graph + one documented edge) so there's a single
source of truth and the regression can't silently drift from the contract.

## Runtime correlation (phase 5)

The same idea for the runtime side: pin the `runtime.json` contract the слой-2 ClrMD/gcdump collector
must emit for the oracle, and exercise `runtime/correlate.py` on it now.

- **`findings.json`** — the static leak SUSPECTS own-check would emit for the oracle's two intentional
lifetime leaks (OWN001 subscription on `WatchlistViewModel`, OWN-TIMER on `TickerViewModel`).
`resource` is a *description*, so correlation keys on the source-file stem (the owning class).
- **`runtime.json`** — the heap evidence, faithful to the headless leak proof: 50 of each leaky
view-model survive GC (`expected` 0), plus the 250 000 `QuoteRow` they transitively retain.
- **`test_oracle_runtime.py`** — runs `correlate.py` and asserts the three-way split:

| bucket | result |
|---|---|
| **confirmed** | `WatchlistViewModel` ×50 + `TickerViewModel` ×50 — both **high**, each naming the leaked CLR type and the static rule it corroborates |
| **static-only** | _(none)_ — both suspects were retained |
| **runtime-only** | `QuoteRow` ×250 000 — a static blind spot (the rows are retained *through* the leaked VMs; the static pass flags the VM, not the pile) |

The `high` gate fails on the two confirmed leaks — the highest-signal finding the auditor produces.
A drift guard ties every retained type to a real `oracle/LeakyOracle/ViewModels/*.cs`.

## Run

```bash
PYTHONPATH=. python3 oracle/fixtures/test_oracle_arch.py
# or the pass directly:
PYTHONPATH=. python3 oracle/fixtures/test_oracle_runtime.py
# or the passes directly:
PYTHONPATH=. python3 -m arch.cli --graph oracle/fixtures/graph.json --rules oracle/fixtures/rules.json
# → architecture pass: 0 findings (clean)
PYTHONPATH=. python3 -m runtime.cli --findings oracle/fixtures/findings.json --runtime oracle/fixtures/runtime.json
# → 2 high confirmed · 0 static-only · 1 runtime-only
```

When слой 2 lands in Own.NET, its extractor run over LeakyOracle should reproduce the **internal**
Expand Down
25 changes: 25 additions & 0 deletions oracle/fixtures/findings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"_comment": "Static leak SUSPECTS for the LeakyOracle app — the findings.json a static pass (own-check) would emit for the oracle's two intentional lifetime leaks. Fed to runtime/correlate.py together with runtime.json to confirm them against heap evidence (test_oracle_runtime.py). `resource` is a DESCRIPTION (has spaces) not a type, so correlate keys on the source-file stem (the owning class) — WatchlistViewModel / TickerViewModel.",
"findings": [
{
"tool": "own-check",
"rule": "OWN001",
"category_name": "subscription-leak",
"resource": "QuoteReceived subscription",
"path": "oracle/LeakyOracle/ViewModels/WatchlistViewModel.cs",
"line": 32,
"message": "subscribes to MarketDataService.QuoteReceived with no matching unsubscribe",
"suppressed": false
},
{
"tool": "own-check",
"rule": "OWN-TIMER",
"category_name": "idisposable-leak",
"resource": "Timer never disposed",
"path": "oracle/LeakyOracle/ViewModels/TickerViewModel.cs",
"line": 27,
"message": "System.Threading.Timer created but never disposed (Stop/Dispose missing)",
"suppressed": false
}
]
}
13 changes: 11 additions & 2 deletions oracle/fixtures/graph.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
{ "id": "T:LeakyOracle.ViewModels.QuoteRow", "kind": "type", "name": "QuoteRow", "namespace": "LeakyOracle.ViewModels", "assembly": "LeakyOracle", "internal": true, "loc": { "file": "oracle/LeakyOracle/ViewModels/QuoteRow.cs", "line": 6 }, "metrics": { "methods": 0, "fields": 1, "loc": 8 } },
{ "id": "T:LeakyOracle.ViewModels.WatchlistViewModel", "kind": "type", "name": "WatchlistViewModel", "namespace": "LeakyOracle.ViewModels", "assembly": "LeakyOracle", "internal": true, "loc": { "file": "oracle/LeakyOracle/ViewModels/WatchlistViewModel.cs", "line": 19 }, "metrics": { "methods": 2, "fields": 3, "loc": 45 } },
{ "id": "T:LeakyOracle.ViewModels.FixedWatchlistViewModel", "kind": "type", "name": "FixedWatchlistViewModel", "namespace": "LeakyOracle.ViewModels", "assembly": "LeakyOracle", "internal": true, "loc": { "file": "oracle/LeakyOracle/ViewModels/FixedWatchlistViewModel.cs", "line": 16 }, "metrics": { "methods": 3, "fields": 3, "loc": 40 } },
{ "id": "T:LeakyOracle.ViewModels.TickerViewModel", "kind": "type", "name": "TickerViewModel", "namespace": "LeakyOracle.ViewModels", "assembly": "LeakyOracle", "internal": true, "loc": { "file": "oracle/LeakyOracle/ViewModels/TickerViewModel.cs", "line": 18 }, "metrics": { "methods": 2, "fields": 2, "loc": 20 } },
{ "id": "T:LeakyOracle.ViewModels.FixedTickerViewModel", "kind": "type", "name": "FixedTickerViewModel", "namespace": "LeakyOracle.ViewModels", "assembly": "LeakyOracle", "internal": true, "loc": { "file": "oracle/LeakyOracle/ViewModels/FixedTickerViewModel.cs", "line": 12 }, "metrics": { "methods": 3, "fields": 2, "loc": 22 } },
{ "id": "T:LeakyOracle.Views.MainWindow", "kind": "type", "name": "MainWindow", "namespace": "LeakyOracle.Views", "assembly": "LeakyOracle", "internal": true, "loc": { "file": "oracle/LeakyOracle/Views/MainWindow.axaml.cs", "line": 5 }, "metrics": { "methods": 1, "fields": 0, "loc": 8 } },

{ "id": "T:Avalonia.Application", "kind": "type", "name": "Application", "namespace": "Avalonia", "assembly": "Avalonia.Base", "internal": false },
Expand All @@ -21,7 +23,8 @@
{ "id": "T:System.Collections.Generic.List`1", "kind": "type", "name": "List", "namespace": "System.Collections.Generic", "assembly": "System.Private.CoreLib", "internal": false },
{ "id": "T:System.Func`1", "kind": "type", "name": "Func", "namespace": "System", "assembly": "System.Private.CoreLib", "internal": false },
{ "id": "T:System.Action`1", "kind": "type", "name": "Action", "namespace": "System", "assembly": "System.Private.CoreLib", "internal": false },
{ "id": "T:System.String", "kind": "type", "name": "String", "namespace": "System", "assembly": "System.Private.CoreLib", "internal": false }
{ "id": "T:System.String", "kind": "type", "name": "String", "namespace": "System", "assembly": "System.Private.CoreLib", "internal": false },
{ "id": "T:System.Threading.Timer", "kind": "type", "name": "Timer", "namespace": "System.Threading", "assembly": "System.Private.CoreLib", "internal": false }
],
"edges": [
{ "from": "T:LeakyOracle.Program", "to": "T:LeakyOracle.App", "kind": "depends" },
Expand Down Expand Up @@ -62,6 +65,12 @@
{ "from": "T:LeakyOracle.ViewModels.QuoteRow", "to": "T:System.String", "kind": "depends" },
{ "from": "T:LeakyOracle.ViewModels.WatchlistViewModel", "to": "T:System.String", "kind": "depends" },
{ "from": "T:LeakyOracle.ViewModels.FixedWatchlistViewModel", "to": "T:System.String", "kind": "depends" },
{ "from": "T:LeakyOracle.LeakScenario", "to": "T:System.String", "kind": "depends" }
{ "from": "T:LeakyOracle.LeakScenario", "to": "T:System.String", "kind": "depends" },

{ "from": "T:LeakyOracle.LeakScenario", "to": "T:LeakyOracle.ViewModels.TickerViewModel", "kind": "depends" },
{ "from": "T:LeakyOracle.LeakScenario", "to": "T:LeakyOracle.ViewModels.FixedTickerViewModel", "kind": "depends" },
{ "from": "T:LeakyOracle.ViewModels.TickerViewModel", "to": "T:System.Threading.Timer", "kind": "depends" },
{ "from": "T:LeakyOracle.ViewModels.FixedTickerViewModel", "to": "T:System.Threading.Timer", "kind": "depends" },
{ "from": "T:LeakyOracle.ViewModels.FixedTickerViewModel", "to": "T:System.IDisposable", "kind": "depends" }
]
}
37 changes: 37 additions & 0 deletions oracle/fixtures/runtime.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"_comment": "Golden heap-retention artifact for the LeakyOracle app — what a stand-side ClrMD/gcdump collector would emit after running `LeakyOracle --leak-scenario` (open+close 50 screens). Faithful to the headless leak proof: 50 of each leaky view-model survive GC (expected 0), plus the 250k QuoteRow they transitively retain. Fed to runtime/correlate.py with findings.json to exercise all three buckets (test_oracle_runtime.py). The слой-2 ClrMD collector in Own.NET must emit this shape; here it's hand-authored from the proven leak.",
"schema": "ownAudit/runtime/v1",
"scenario": "open+close 50 watchlist/ticker screens (LeakyOracle --leak-scenario)",
"iterations": 50,
"retained": [
{
"type": "LeakyOracle.ViewModels.WatchlistViewModel",
"count": 50,
"expected": 0,
"bytes": 11534336,
"roots": [
{ "kind": "static-field", "holder": "Avalonia.Application", "member": "Current",
"via": "App._service (MarketDataService).QuoteReceived delegate -> this" }
]
},
{
"type": "LeakyOracle.ViewModels.TickerViewModel",
"count": 50,
"expected": 0,
"roots": [
{ "kind": "timer", "holder": "System.Threading.TimerQueue",
"via": "undisposed Timer callback -> this" }
]
},
{
"type": "LeakyOracle.ViewModels.QuoteRow",
"count": 250000,
"expected": 0,
"bytes": 10485760,
"roots": [
{ "kind": "static-field", "holder": "Avalonia.Application", "member": "Current",
"via": "retained transitively through the 50 leaked WatchlistViewModel.Rows" }
]
}
]
}
Loading
Loading