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
30 changes: 30 additions & 0 deletions AppDataStorage.Test/AppDataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ namespace ktsu.AppDataStorage.Test;
using System.IO.Abstractions;
using System.IO.Abstractions.TestingHelpers;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Text.Json;

using ktsu.CaseConverter;
Expand Down Expand Up @@ -474,6 +475,35 @@ public void TestDisposeCanBeCalledMultipleTimes()
Assert.IsTrue(appData.IsDisposeRegistered, "IsDisposeRegistered should remain true after multiple disposals.");
}

// Kept out of the test body so the instance cannot survive in a local slot the JIT has not yet
// reported dead, which would make the weak reference stay alive for reasons unrelated to the
// event handler.
[MethodImpl(MethodImplOptions.NoInlining)]
private static WeakReference RegisterQueueSaveAndDispose()
{
TestAppData appData = new();
appData.QueueSave();
Assert.IsTrue(appData.IsDisposeRegistered, "QueueSave should have registered the process-exit handler.");
appData.Dispose();
return new WeakReference(appData);
}

[TestMethod]
public void TestDisposeReleasesTheProcessExitHandlerSoTheInstanceCanBeCollected()
{
// The handler closes over the instance and AppDomain.CurrentDomain lives for the whole
// process, so failing to unsubscribe roots every instance that ever queued a save. That is
// invisible for a single Get() singleton but unbounded for a process that churns instances
// through LoadOrCreate.
WeakReference reference = RegisterQueueSaveAndDispose();

GC.Collect();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.WaitForPendingFinalizers();
GC.Collect();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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


Assert.IsFalse(reference.IsAlive, "A disposed instance should not still be rooted by the ProcessExit handler.");
}

[TestMethod]
public void TestGetReturnsSameInstance()
{
Expand Down
19 changes: 18 additions & 1 deletion AppDataStorage/AppData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,11 @@
{
private bool disposedValue;

// Held so the handler can be removed again. AppDomain.CurrentDomain lives for the process, so a
// handler closing over this instance roots it until the process ends; without a reference to the
// exact delegate there is no way to unsubscribe, and disposing the instance would not release it.
private EventHandler? processExitHandler;

/// <summary>
/// Gets the file name for the app data file.
/// </summary>
Expand Down Expand Up @@ -408,10 +413,10 @@
/// </summary>
#if NET9_0_OR_GREATER
[JsonIgnore]
public static Lock Lock { get; } = new();

Check warning on line 416 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 416 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 416 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 416 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.
#else
[JsonIgnore]
public static object Lock { get; } = new();

Check warning on line 419 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 419 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 419 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 419 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 419 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 419 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.
#endif

internal bool IsSaveQueued()
Expand Down Expand Up @@ -454,7 +459,8 @@
if (!IsDisposeRegistered)
{
IsDisposeRegistered = true;
AppDomain.CurrentDomain.ProcessExit += (sender, e) => Dispose();
processExitHandler = (sender, e) => Dispose();
AppDomain.CurrentDomain.ProcessExit += processExitHandler;
}
}
}
Expand All @@ -474,6 +480,17 @@
Save();
}

// Release the process-exit root. Unconditional rather than under `disposing`, because
// it touches only AppDomain.CurrentDomain, which outlives every instance and is safe
// to reach from a finalizer. IsDisposeRegistered deliberately stays true: it records
// that this instance has been registered, and leaving it set stops a later
// EnsureDisposeOnExit re-rooting an already-disposed instance.
if (processExitHandler is not null)
{
AppDomain.CurrentDomain.ProcessExit -= processExitHandler;
processExitHandler = null;
}

disposedValue = true;
}
}
Expand Down
Loading