Unsubscribe the ProcessExit handler on dispose - #312
Conversation
EnsureDisposeOnExit subscribed a lambda closing over the instance to AppDomain.CurrentDomain.ProcessExit and kept no reference to it, so there was nothing to unsubscribe. AppDomain.CurrentDomain lives for the process, so every instance that ever queued a save stayed rooted for the rest of the run, and Dispose did not release it. Invisible for a single Get() singleton, but a process that churns instances through LoadOrCreate - one per save slot, profile or document - accumulated them without bound, which is what implementing IDisposable on this type is for. Store the delegate and remove it in Dispose(bool). IsDisposeRegistered stays true afterwards, as the existing multiple-dispose test requires, and that also stops a later EnsureDisposeOnExit re-rooting a disposed instance. Fixes #311 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Pup1E96GmvMiRTNMdABat
| // through LoadOrCreate. | ||
| WeakReference reference = RegisterQueueSaveAndDispose(); | ||
|
|
||
| GC.Collect(); |
There was a problem hiding this comment.
Checked this one, and it stays as written — reasoning on the companion alert at line 502, which flags the second GC.Collect() in the same three-call sequence.
Generated by Claude Code
|
|
||
| GC.Collect(); | ||
| GC.WaitForPendingFinalizers(); | ||
| GC.Collect(); |
There was a problem hiding this comment.
Verified, and the call sequence stays. The rule is right about the pattern and wrong about this use, and the suggested replacement would remove what the test measures.
The alert is a true positive on the pattern. cs/call-to-gc exists because forcing a collection in production code is a performance anti-pattern — you pay a full GC to get behaviour the runtime would have managed better itself. That reasoning does not reach a test whose assertion is reachability.
There is no alternative API. The test asserts that a disposed AppData<T> is no longer rooted by the ProcessExit handler — the leak this PR fixes. The only way to observe that from managed code is to force a collection and then check a WeakReference. .NET exposes nothing else: reachability is not queryable, and an object is only proven unreachable by collecting it.
The suggested fix does not work, and the comment says so itself. It offers three alternatives and disclaims each one in the same breath — GC.KeepAlive is "not appropriate ... but is unnecessary", WaitForFullGCComplete() "is not equivalent and depends on notifications" — before landing on "poll briefly with GC.WaitForPendingFinalizers() only". That last one is the problem: WaitForPendingFinalizers waits for the finalizer queue to drain, it does not collect anything. With no GC.Collect() the instance is never collected, reference.IsAlive stays true however long you poll, and the test fails — against the fixed code. It would not preserve test intent; it would invert the result.
The three calls are each load-bearing, which is why there are three rather than one:
| call | why |
|---|---|
GC.Collect() |
queues the unreachable instance for finalization |
GC.WaitForPendingFinalizers() |
lets finalizers run, so a resurrected object settles |
GC.Collect() |
reclaims what finalization released |
Dropping the second Collect would make the test flaky rather than wrong, since a finalizable object survives its first collection.
On suppressing it instead: [SuppressMessage] is a Roslyn mechanism and has no effect on a CodeQL alert, so adding one here would look like a fix without being one. I have not pushed one. If you would rather this stopped recurring on future PRs, the options are a // codeql[cs/call-to-gc] inline suppression or dismissing the alert in the code-scanning UI as "used in tests" — both are repo-policy calls rather than something to decide inside a bug fix, so I have left it to you.
Worth noting the gating check agrees: CodeQL itself reported success on this head, as did github-advanced-security, alongside green tests on ubuntu, windows and macos. This is an advisory inline note, not a failing signal.
Generated by Claude Code
|



Fixes #311
The defect
EnsureDisposeOnExitsubscribed a lambda toAppDomain.CurrentDomain.ProcessExitand kept no reference to it:The lambda closes over
this, andAppDomain.CurrentDomainlives for the whole process — so the instance was rooted from the moment it first queued a save. With no reference to the exact delegate there was nothing to pass to-=, soDisposecould not release it either.Invisible for the common case of one
Get()singleton. Not invisible for the multi-instance use the library explicitly supports and tests: a long-running process creating instances throughLoadOrCreate(subdirectory)/LoadOrCreate(fileName)— one per save slot, profile or document — accumulates permanently rooted instances in proportion to churn, which is the thing implementingIDisposableon this type is meant to prevent.The change
The delegate is held in a field and removed in
Dispose(bool disposing).Two decisions worth stating, since both are visible in the diff:
IsDisposeRegistereddeliberately staystrueafter disposal. The existingTestDisposeCanBeCalledMultipleTimesalready pins this ("IsDisposeRegistered should remain true after multiple disposals"), and keeping it set is also the safer behaviour: it means a laterQueueSaveon a disposed instance cannot re-enter theif (!IsDisposeRegistered)branch and root it again, this time with a handler whoseDispose()is a no-op. Resetting the flag would have reintroduced the leak on a narrower path.The unsubscribe sits outside the
disposingcheck, inside!disposedValue. It touches onlyAppDomain.CurrentDomain, a static that outlives every instance, so it is safe to reach from a finalizer. In practice the finalizer path is unreachable while a handler is registered — a rooted object is never finalized — but making it conditional ondisposingwould encode the opposite assumption for no benefit.Test
TestDisposeReleasesTheProcessExitHandlerSoTheInstanceCanBeCollectedasserts the thing that was actually wrong — reachability — rather than the shape of the fix. It queues a save (which is what registers the handler), disposes, drops the reference and collects, then asserts aWeakReferenceto it is dead.A test that merely asserted a private field was null would pass against a fix that nulled the field without unsubscribing, which is the obvious way to get this wrong.
The instance is created and dropped inside a
[MethodImpl(MethodImplOptions.NoInlining)]helper. Without that, it can stay alive in a local slot the JIT has not yet reported dead, and the weak reference survives for reasons unrelated to the event handler — a false pass in Debug and a flaky one in Release.Proved failing without the fix. Reverting only
AppDataStorage/AppData.csand keeping the test:It fails on the instance still being reachable — the leak itself. The 103 pre-existing tests are unaffected in both directions, including the two that pin
IsDisposeRegistered.Verification
dotnet build AppDataStorage.sln -c Release— succeeded, 0 errorsdotnet test AppDataStorage.sln -c Release— 104 total, 104 passed, 0 failed, 0 skippedAppData.cs— 1 of 104 failed, as aboveRun on .NET SDK 10.0.401, Linux.
The build emits 3 warnings, all
System.Text.Encodings.Web/System.IO.Pipelines/System.Text.Json10.0.12 reporting nonet7.0support for the library'snet7.0leg. They are pre-existing and unrelated — they reproduce identically with this change reverted, and concern transitive package TFM support rather than anything in this diff.Not in this change
#310— the interrupted-write window inWriteText/ReadText— is untouched. Same file, entirely separate fault.🤖 Generated with Claude Code
https://claude.ai/code/session_012Pup1E96GmvMiRTNMdABat
Generated by Claude Code