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
108 changes: 108 additions & 0 deletions NoSleep.Test/AcquireAfterDisposeTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Checks that every platform blocker refuses an <see cref="ISleepBlocker.Acquire"/> that queued on its lock
/// behind <see cref="IDisposable.Dispose"/>.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[TestClass]
public class AcquireAfterDisposeTests
{
[TestMethod]
[SupportedOSPlatform("linux")]

Check warning on line 28 in NoSleep.Test/AcquireAfterDisposeTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Platform compatibility attributes on test 'Linux_AcquireQueuedBehindDispose_Throws' are not represented by a compatible '[OSCondition]' attribute

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_NoSleep&issues=AaGkMeO6rtdIBBPO5RYa&open=AaGkMeO6rtdIBBPO5RYa&pullRequest=36
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")]

Check warning on line 37 in NoSleep.Test/AcquireAfterDisposeTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Platform compatibility attributes on test 'MacOs_AcquireQueuedBehindDispose_Throws' are not represented by a compatible '[OSCondition]' attribute

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_NoSleep&issues=AaGkMeO6rtdIBBPO5RYb&open=AaGkMeO6rtdIBBPO5RYb&pullRequest=36
public void MacOs_AcquireQueuedBehindDispose_Throws()
{
using MacOsSleepBlocker blocker = new();
AssertAcquireQueuedBehindDisposeThrows(blocker, GateOf(blocker));
}

[TestMethod]
[SupportedOSPlatform("windows")]

Check warning on line 45 in NoSleep.Test/AcquireAfterDisposeTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Platform compatibility attributes on test 'Windows_AcquireQueuedBehindDispose_Throws' are not represented by a compatible '[OSCondition]' attribute

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_NoSleep&issues=AaGkMeO6rtdIBBPO5RYZ&open=AaGkMeO6rtdIBBPO5RYZ&pullRequest=36
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<ObjectDisposedException>(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);
}
}
}
118 changes: 118 additions & 0 deletions NoSleep.Test/LinuxSleepBlockerTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests for <see cref="LinuxSleepBlocker"/> run against a stand-in <c>systemd-inhibit</c>.
/// </summary>
/// <remarks>
/// The stand-in records its process id and then becomes <c>cat</c>, 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.
/// </remarks>
[TestClass]
public class LinuxSleepBlockerTests
{
[TestMethod]
[SupportedOSPlatform("linux")]

Check warning on line 27 in NoSleep.Test/LinuxSleepBlockerTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Platform compatibility attributes on test 'Acquire_QueuedBehindDispose_ThrowsAndStartsNoHelper' are not represented by a compatible '[OSCondition]' attribute

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_NoSleep&issues=AaGX_K135xr-Pq-Fgwsu&open=AaGX_K135xr-Pq-Fgwsu&pullRequest=36
public void Acquire_QueuedBehindDispose_ThrowsAndStartsNoHelper()
{
if (!OperatingSystem.IsLinux())
{
Assert.Inconclusive("LinuxSleepBlocker only runs on Linux.");
return;
}

Check warning on line 34 in NoSleep.Test/LinuxSleepBlockerTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[OSCondition]' attribute instead of 'RuntimeInformation.IsOSPlatform' calls with early return or 'Assert.Inconclusive'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_NoSleep&issues=AaGX_K135xr-Pq-Fgwsv&open=AaGX_K135xr-Pq-Fgwsv&pullRequest=36

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<ObjectDisposedException>(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);
}
}
}
18 changes: 16 additions & 2 deletions NoSleep/Platforms/LinuxSleepBlocker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,19 @@ public sealed class LinuxSleepBlocker : ISleepBlocker
/// helpers available on this machine.
/// </summary>
public LinuxSleepBlocker()
: this(ExecutableLocator.Find(SystemdInhibit), ExecutableLocator.Find(GnomeSessionInhibit))
{
systemdInhibitPath = ExecutableLocator.Find(SystemdInhibit);
gnomeSessionInhibitPath = ExecutableLocator.Find(GnomeSessionInhibit);
}

/// <summary>
/// Initializes a new instance of the <see cref="LinuxSleepBlocker"/> class with the given helper paths.
/// </summary>
/// <param name="systemdInhibitPath">The <c>systemd-inhibit</c> executable, or <see langword="null"/> when there is none.</param>
/// <param name="gnomeSessionInhibitPath">The <c>gnome-session-inhibit</c> executable, or <see langword="null"/> when there is none.</param>
internal LinuxSleepBlocker(string? systemdInhibitPath, string? gnomeSessionInhibitPath)
{
this.systemdInhibitPath = systemdInhibitPath;
this.gnomeSessionInhibitPath = gnomeSessionInhibitPath;
}

/// <inheritdoc/>
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions NoSleep/Platforms/MacOsSleepBlocker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions NoSleep/Platforms/WindowsSleepBlocker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading