diff --git a/NoSleep.Test/AcquireAfterDisposeTests.cs b/NoSleep.Test/AcquireAfterDisposeTests.cs new file mode 100644 index 0000000..83f6f1d --- /dev/null +++ b/NoSleep.Test/AcquireAfterDisposeTests.cs @@ -0,0 +1,108 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.NoSleep.Test; + +using System; +using System.Diagnostics; +using System.Reflection; +using System.Runtime.Versioning; +using System.Threading; +using ktsu.NoSleep.Contracts; +using ktsu.NoSleep.Platforms; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Checks that every platform blocker refuses an that queued on its lock +/// behind . +/// +/// +/// These run on every operating system, not only the blocker's own. The check under the lock throws before any +/// platform API is reached, and releasing a blocker that holds nothing makes no platform call either, so the +/// path under test is the same managed code everywhere. Each method still names its blocker's platform so the +/// platform analyzer sees the call is deliberate. +/// +[TestClass] +public class AcquireAfterDisposeTests +{ + [TestMethod] + [SupportedOSPlatform("linux")] + public void Linux_AcquireQueuedBehindDispose_Throws() + { + // A path that is never started: the disposed check under the lock throws first. + using LinuxSleepBlocker blocker = new("systemd-inhibit-never-started", null); + AssertAcquireQueuedBehindDisposeThrows(blocker, GateOf(blocker)); + } + + [TestMethod] + [SupportedOSPlatform("macos")] + public void MacOs_AcquireQueuedBehindDispose_Throws() + { + using MacOsSleepBlocker blocker = new(); + AssertAcquireQueuedBehindDisposeThrows(blocker, GateOf(blocker)); + } + + [TestMethod] + [SupportedOSPlatform("windows")] + public void Windows_AcquireQueuedBehindDispose_Throws() + { + using WindowsSleepBlocker blocker = new(); + AssertAcquireQueuedBehindDisposeThrows(blocker, GateOf(blocker)); + } + + private static Lock GateOf(ISleepBlocker blocker) => + (Lock)blocker.GetType() + .GetField("gate", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(blocker)!; + + private static void AssertAcquireQueuedBehindDisposeThrows(ISleepBlocker blocker, Lock gate) + { + Exception? acquireFailure = null; + Thread acquirer = new(() => + { + try + { + blocker.Acquire(SleepBlockRequest.SystemOnly); + } + catch (ObjectDisposedException ex) + { + acquireFailure = ex; + } + }); + + // Hold the lock so the Acquire passes its up-front disposed check and then queues on the lock, which is + // where an Acquire racing a Dispose on another thread ends up. + gate.Enter(); + + try + { + acquirer.Start(); + WaitUntilBlocked(acquirer); + + // The lock is re-entrant, so Dispose runs to completion while the Acquire is still queued. + blocker.Dispose(); + } + finally + { + gate.Exit(); + } + + Assert.IsTrue(acquirer.Join(TimeSpan.FromSeconds(10)), "The queued Acquire should finish once the lock is free"); + Assert.IsInstanceOfType(acquireFailure, "An Acquire queued behind Dispose should see the blocker as disposed"); + Assert.IsFalse(blocker.IsBlocking, "A disposed blocker should not report that it is blocking"); + } + + private static void WaitUntilBlocked(Thread thread) + { + Stopwatch elapsed = Stopwatch.StartNew(); + + while ((thread.ThreadState & System.Threading.ThreadState.WaitSleepJoin) == 0) + { + if (elapsed.Elapsed > TimeSpan.FromSeconds(10)) + { + Assert.Fail("The Acquire never queued on the blocker's lock"); + } + + Thread.Sleep(10); + } + } +} diff --git a/NoSleep.Test/LinuxSleepBlockerTests.cs b/NoSleep.Test/LinuxSleepBlockerTests.cs new file mode 100644 index 0000000..b8061a5 --- /dev/null +++ b/NoSleep.Test/LinuxSleepBlockerTests.cs @@ -0,0 +1,118 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.NoSleep.Test; + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Versioning; +using System.Threading; +using ktsu.NoSleep.Platforms; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests for run against a stand-in systemd-inhibit. +/// +/// +/// The stand-in records its process id and then becomes cat, the same "stay alive until standard input +/// closes" shape as the real hold, so a test can tell whether any helper outlived the blocker. They are skipped +/// off Linux, where the blocker does not run. +/// +[TestClass] +public class LinuxSleepBlockerTests +{ + [TestMethod] + [SupportedOSPlatform("linux")] + public void Acquire_QueuedBehindDispose_ThrowsAndStartsNoHelper() + { + if (!OperatingSystem.IsLinux()) + { + Assert.Inconclusive("LinuxSleepBlocker only runs on Linux."); + return; + } + + string root = Directory.CreateTempSubdirectory("nosleep-linux-").FullName; + + try + { + string pidFile = Path.Join(root, "pids"); + string helper = Path.Join(root, "systemd-inhibit"); + File.WriteAllText(helper, $"#!/bin/sh\necho $$ >> '{pidFile}'\nexec cat\n"); + File.SetUnixFileMode(helper, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + LinuxSleepBlocker blocker = new(helper, null); + Lock gate = (Lock)typeof(LinuxSleepBlocker) + .GetField("gate", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(blocker)!; + + Exception? acquireFailure = null; + Thread acquirer = new(() => acquireFailure = TryAcquire(blocker)); + + // Hold the blocker's lock so the Acquire passes its up-front disposed check and then queues on the + // lock, which is where an Acquire racing a Dispose on another thread ends up. + gate.Enter(); + + try + { + acquirer.Start(); + WaitUntilBlocked(acquirer); + + // The lock is re-entrant, so Dispose runs to completion while the Acquire is still queued. + blocker.Dispose(); + } + finally + { + gate.Exit(); + } + + Assert.IsTrue(acquirer.Join(TimeSpan.FromSeconds(10)), "The queued Acquire should finish once the lock is free"); + + int[] helpers = File.Exists(pidFile) + ? [.. File.ReadAllLines(pidFile).Where(line => line.Length > 0).Select(int.Parse)] + : []; + + // Clean up before asserting, so a failure here does not leave a helper running for the rest of the run. + bool leftBlocking = blocker.IsBlocking; + blocker.Release(); + + Assert.IsInstanceOfType(acquireFailure, "An Acquire queued behind Dispose should see the blocker as disposed"); + Assert.IsFalse(leftBlocking, "A disposed blocker should not report that it is blocking"); + Assert.IsEmpty(helpers, "No helper should have been started for a disposed blocker"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [SupportedOSPlatform("linux")] + private static ObjectDisposedException? TryAcquire(LinuxSleepBlocker blocker) + { + try + { + blocker.Acquire(SleepBlockRequest.SystemOnly); + return null; + } + catch (ObjectDisposedException ex) + { + return ex; + } + } + + private static void WaitUntilBlocked(Thread thread) + { + Stopwatch elapsed = Stopwatch.StartNew(); + + while ((thread.ThreadState & System.Threading.ThreadState.WaitSleepJoin) == 0) + { + if (elapsed.Elapsed > TimeSpan.FromSeconds(10)) + { + Assert.Fail("The Acquire never queued on the blocker's lock"); + } + + Thread.Sleep(10); + } + } +} diff --git a/NoSleep/Platforms/LinuxSleepBlocker.cs b/NoSleep/Platforms/LinuxSleepBlocker.cs index 8f2e656..de3a94e 100644 --- a/NoSleep/Platforms/LinuxSleepBlocker.cs +++ b/NoSleep/Platforms/LinuxSleepBlocker.cs @@ -42,9 +42,19 @@ public sealed class LinuxSleepBlocker : ISleepBlocker /// helpers available on this machine. /// public LinuxSleepBlocker() + : this(ExecutableLocator.Find(SystemdInhibit), ExecutableLocator.Find(GnomeSessionInhibit)) { - systemdInhibitPath = ExecutableLocator.Find(SystemdInhibit); - gnomeSessionInhibitPath = ExecutableLocator.Find(GnomeSessionInhibit); + } + + /// + /// Initializes a new instance of the class with the given helper paths. + /// + /// The systemd-inhibit executable, or when there is none. + /// The gnome-session-inhibit executable, or when there is none. + internal LinuxSleepBlocker(string? systemdInhibitPath, string? gnomeSessionInhibitPath) + { + this.systemdInhibitPath = systemdInhibitPath; + this.gnomeSessionInhibitPath = gnomeSessionInhibitPath; } /// @@ -106,6 +116,10 @@ public void Acquire(SleepBlockRequest request) lock (gate) { + // Checked again under the lock: an Acquire that passed the check above and then queued behind + // Dispose would otherwise start holds on a disposed blocker that nothing would ever release. + ObjectDisposedException.ThrowIf(disposed, this); + ReleaseCore(); try diff --git a/NoSleep/Platforms/MacOsSleepBlocker.cs b/NoSleep/Platforms/MacOsSleepBlocker.cs index a6b2a97..23d1c44 100644 --- a/NoSleep/Platforms/MacOsSleepBlocker.cs +++ b/NoSleep/Platforms/MacOsSleepBlocker.cs @@ -69,6 +69,10 @@ public void Acquire(SleepBlockRequest request) lock (gate) { + // Checked again under the lock: an Acquire that passed the check above and then queued behind + // Dispose would otherwise start holds on a disposed blocker that nothing would ever release. + ObjectDisposedException.ThrowIf(disposed, this); + ReleaseCore(); try diff --git a/NoSleep/Platforms/WindowsSleepBlocker.cs b/NoSleep/Platforms/WindowsSleepBlocker.cs index 401064b..8526a8f 100644 --- a/NoSleep/Platforms/WindowsSleepBlocker.cs +++ b/NoSleep/Platforms/WindowsSleepBlocker.cs @@ -62,6 +62,10 @@ public void Acquire(SleepBlockRequest request) lock (gate) { + // Checked again under the lock: an Acquire that passed the check above and then queued behind + // Dispose would otherwise start holds on a disposed blocker that nothing would ever release. + ObjectDisposedException.ThrowIf(disposed, this); + ReleaseCore(); ManualResetEventSlim signal = new(initialState: false);