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);