Memory leaks and diagnostics
Skill Sarmkadan/dotnet-senior-skills/skills/memory-leaks-and-diagnostics
Senior-level .NET review rules for AI coding agents - Claude Code skills, Cursor rules, and Copilot instructions from one source
npx -y skills add Sarmkadan/dotnet-senior-skills --skill memory-leaks-and-diagnosticsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- 26 days oldThe repository was created 26 days ago. New is not bad, but a brand new repository carrying a familiar-sounding name is the shape a typosquat arrives in, and there has been no time for anyone else to find a problem with it.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Review .NET code for managed memory leaks - event handler leaks, static caches, timers, CancellationTokenRegistration, closure captures - and how to diagnose with dotnet-counters/gcdump. Use when reviewing long-lived objects, event subscriptions, or investigating memory growth.
SKILL.md
5.2 KB, ~1.1k tokens by cl100k_base, as published. Nobody here has run it
Memory Leaks and Diagnostics
Managed leaks are reachability bugs
The GC collects what is unreachable; a ".NET memory leak" is always something still holding a reference - usually a long-lived object (static, singleton, cache) pointing at things meant to be short-lived. Review long-lived state with one question: "what removes entries from this?"
Event handlers: the classic
publisher.Event += subscriber.Handler stores a reference to the subscriber inside the publisher. Long-lived publisher + short-lived subscriber = every subscriber ever, retained:
// non-compiling: illustrative
// WRONG: scoped service subscribing to a singleton's event - one leaked scope graph per request
public OrderProcessor(GlobalEvents events) => events.OrderPlaced += OnOrderPlaced;
// RIGHT: unsubscribe symmetrically - which means the type is IDisposable
public void Dispose() => _events.OrderPlaced -= OnOrderPlaced;
Review flags: any += on an event of a longer-lived object without the matching -= in Dispose; static event (subscribers live until process exit); lambda subscriptions (events.X += (s, e) => ...) that can never be unsubscribed because the delegate instance is gone. Prefer not having the mismatch at all: singleton-to-singleton events are fine; cross-lifetime notification is better served by Channel<T>/messaging than events.
The other repeat offenders
- Static/singleton collections as ad-hoc caches:
static Dictionary<Guid, UserState>with adds and no eviction is a leak with a business justification. Real cache types with bounds (IMemoryCache+SizeLimit,MemoryCacheEntryOptionsexpirations) - an unbounded cache is a leak with a nicer name (performance skill). - Timers:
System.Threading.Timer/System.Timers.Timerroot their callback (and its capturedthis) until disposed. A timer field on a non-disposed object keeps the whole object alive - and the timer keeps firing against it. - CancellationTokenRegistration:
token.Register(callback)on a long-lived token (app shutdown token, a linked source that outlives the operation) accumulates one registration per call forever. Dispose registrations (using var reg = token.Register(...), orCancellationTokenRegistration.Unregister), and dispose linkedCancellationTokenSources - each links registrations into its parents. - Closure captures: a lambda stored long-term (cache value factory, event, callback registry) captures its enclosing locals - including that 200MB parsed document you only needed for one field. Extract the field before the lambda; capture the minimum. Same mechanism as
staticlambda guidance in the performance skill, different failure: there it is allocation rate, here it is retention. - Async state machines held by pending operations: a
TaskCompletionSourcethat is never completed roots every awaiter's stack. Every TCS needs a timeout/cancellation path to guaranteed completion. - String/array slices in old code:
Substringcopies (fine);Memory<T>.Sliceover a rented or huge array retains the whole backing array while the slice lives - do not store slices of pooled buffers past the return.
LOH and fragmentation
Objects >= 85KB land on the Large Object Heap - collected only with Gen2, not compacted by default. Per-request new byte[1MB] buffers or giant MemoryStreams mean Gen2 pressure and fragmentation that looks like a leak in working-set graphs. Fixes in order: stream instead of buffering (serialization skill), ArrayPool for transient large buffers, RecyclableMemoryStream for repeated stream use.
Diagnose before you patch
Memory "leaks" get fixed by guesswork more than any other bug class; the tooling makes guessing unnecessary:
dotnet-counters monitor -p <pid> --counters System.Runtime # heap sizes, gen0/1/2 rates, % time in GC
dotnet-gcdump collect -p <pid> # heap snapshot, safe-ish in prod
Method: two gcdumps separated by the suspected growth period, diff by retained size (Visual Studio / PerfView / dotnet-heapview) - the leaking type and its retention path to a root fall out directly; the fix is severing that path. Rules of engagement:
- Rising working set alone is not a leak - the GC keeps committed memory it expects to reuse, and Server GC idles high by design. Leak = Gen2/LOH size growing monotonically across many Gen2 collections under steady load.
GC.Collect()in application code to "fix" memory: rejection. It masks the retention bug, stalls the process, and defeats the GC's own tuning. Legitimate only in measurement harnesses.- Container limits: the GC reads cgroup limits; a container OOMKilled with a small heap usually means non-GC memory (native deps, buffers, sockets) or a limit set below baseline - check
GCHeapHardLimitmath before blaming the app. - Every leak fix ships with the evidence: the gcdump diff before, flat line after. "Restarted nightly as a workaround" is an incident deferral, not a fix - say so in the ticket.