In this post I describe a small .NET tool that I built to force a Windows PC to go to sleep after a timer expires. I describe the Win32 API I used to send my laptop to sleep, and how I expanded the proof of concept to be a Native AOT-compiled .NET tool that you can view on GitHub or install from Nuget.
Background: go to sleep!
Getting a laptop to go to sleep seems to be one of those things that's just way harder than it should be. Whether it's a MacBook that decides to wake up and heat my backpack over night, or a Windows laptop that just won't go to sleep, I always seem to have issues.
One weekend, I was wrestling with exactly the latter issue: I just couldn't get my Windows laptop to sleep. Specifically, I couldn't get it to sleep when Windows Media Player Legacy finished playing a playlist. As someone that goes to sleep with videos playing in the background, it's very annoying…
Yes, I know it's old. Yes, I use VLC too. I still prefer the old WMP for some things, namely adding a few videos to a playlist from my library, seeing the last played time etc
What's particularly annoying is that I've tried every troubleshooting approach under the sun. The power plans are correct. I've run and explored powercfg. I did it all, man. And eventually I just got bored and wrote a tiny app that forces the laptop to sleep after a given time limit has expired.
Sending a PC to sleep with SetSuspendState
The first version I knocked out in just a few minutes, and it looked something like this:
using System.Runtime.InteropServices;
var wait = TimeSpan.FromSeconds(60 * 60); // 1 hour
Console.WriteLine("Waiting for {wait}");
Thread.Sleep(wait);
Console.WriteLine("Sleeping!");
SetSuspendState(false, false, false);
[DllImport("PowrProf.dll", SetLastError = true)]
static extern bool SetSuspendState(bool hibernate, bool forceCritical, bool disableWakeEvent);
This tiny program simply sleeps for a hardcoded period of time (1 hour for my purposes) and then makes a P/Invoke call to the SetSuspendState method in PowrProf.dll, a Win32 API (i.e. Windows only) which sends the laptop to sleep.
I set
hibernate=falsehere, however it appears that Windows often still hibernates regardless of this value, depending on your system settings. This can apparently happen particularly when Hybrid Sleep is enabled. In my case it does hibernate, which is fine for me, as that's what I really wanted anyway.
And that's pretty much all you need to implement the functionality I was after! Of course, I couldn't quite leave it at simple as that.
Adding some pizzazz
Given this was such a little tool, my first thoughts were:
- Add some proper command-line arg parsing
- Package it as a Native AOT tool using the new .NET 10 tools support
- Jazz up the console somewhat
- Allow a "dry run" option
I first set about adding command-line parsing and help generation as a quick way for testing various options.
Using ConsoleAppFramework to create console apps
There's lots of different options for this, for example:
ConsoleAppFramework is a project I haven't seen mentioned very much, but it's one I've used in the past that I've had a good experience with. As per the project description:
ConsoleAppFramework v5 is Zero Dependency, Zero Overhead, Zero Reflection, Zero Allocation, AOT Safe CLI Framework powered by C# Source Generator; achieves exceptionally high performance, fastest start-up time(with NativeAOT) and minimal binary size. Leveraging the latest features of .NET 8 and C# 13 (IncrementalGenerator, managed function pointer, params arrays and default values lambda expression,
ISpanParsable<T>,PosixSignalRegistration, etc.), this library ensures maximum performance while maintaining flexibility and extensibility.
Which you know, is all pretty cool😄 From my point of view, what I like the most is the simplicity, but it also ticks a lot of boxes for performance. It does require a modern version of .NET (.NET 8+) but that's fine with me in this case.
Add the package to your project:
dotnet add package ConsoleAppFramework
Converting my little proof of concept to a "real" console app, with help text, argument parsing and validation was as simple as this:
using System.ComponentModel.DataAnnotations;
using ConsoleAppFramework;
using System.Runtime.InteropServices;
await ConsoleApp.RunAsync(args, App.Countdown);
static partial class App
{
/// <summary>
/// Waits for the provided number of seconds before sending the computer to sleep
/// </summary>
/// <param name="sleepDelaySeconds">-s|--seconds, The time in seconds before the computer is put to sleep. Defaults to 1 hour</param>
/// <param name="dryRun">If true, prints a message instead of sleeping</param>
/// <param name="ct">Used to cancel execution</param>
/// <returns></returns>
public static async Task Countdown(
[Range(1, 99 * 60 * 60)]uint sleepDelaySeconds = 60 * 60,
bool dryRun = false,
CancellationToken ct = default)
{
var wait = TimeSpan.FromSeconds(sleepDelaySeconds);
Console.WriteLine("Waiting for {wait}");
await Task.Delay(wait, ct);
Console.WriteLine("Sleeping!");
if (!dryRun)
{
SetSuspendState(false, false, false);
}
}
[DllImport("PowrProf.dll", SetLastError = true)]
static extern bool SetSuspendState(bool hibernate, bool forceCritical, bool disableWakeEvent);
}
To add ConsoleAppFramework support I just had to:
- Call the source-generated
ConsoleApp.RunAsync()method - Decorate my target method with XML comments describing the command, the arguments, and validation requirements
This generates all the help text you would expect:
> dotnet run -- --help
Usage: [options...] [-h|--help] [--version]
Waits for the provided number of seconds before sending the computer to sleep
Options:
-s|--seconds|--sleep-delay-seconds <uint> The time in seconds before the computer is put to sleep. Defaults to 1 hour (Default: 3600)
--dry-run If true, prints a message instead of sleeping (Optional)
Pretty neat!
Being source generated, you can even F12 the implementation and see exactly what it's doing, but I'm not going to dig into that here. You can see I also added the "dry run" option, so that I could easily test without my laptop going to sleep repeatedly!
Adding Native AOT support
Adding Native AOT support was pretty simple, as ConsoleAppFramework already supports Native AOT, and the app isn't doing much else. I added the <PublishAot>true</PublishAot> setting to my project file, and there were no build warnings.
I was somewhat surprised to find that [DllImport] wasn't flagged, as I thought you had to use the new [LibraryImport] source-generator-based attribute, but apparently that's not always necessary. The docs still recommend using it though, so I switched to the new attribute, specifying all the marshalling value as required:
[LibraryImport("PowrProf.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)] // return is actually BOOL (int)
private static partial bool SetSuspendState(
[MarshalAs(UnmanagedType.U1)] bool hibernate,
[MarshalAs(UnmanagedType.U1)] bool forceCritical,
[MarshalAs(UnmanagedType.U1)] bool disableWakeEvent);
After making this change I tested publishing a Native AOT version of the app using
dotnet publish -r win-x64 -c Release
And the resulting sleep-pc.exe file was 3.3MB—not bad for a .NET app including all the runtime bits it needs! But I wondered if I could push that further…
After looking through the trimming options documentation, I found a bunch of knobs and levers to pull and ultimately managed to shave off ~0.3MB. Not a massive additional improvement, and maybe I could have gone smaller, but meh, good enough! I show the final .csproj I ended up with in the next section.
Michal Strehovský's Sizoscope project is a neat way to "peak inside" your Native AOT binaries to see where all that space is going, and to give you ideas for things you could remove.
With the tool now publishing as Native AOT, I worked on publishing it as a NuGet package.
Packaging as a Native AOT tool
I've written a lot recently about .NET tools, and particularly the .NET 10 feature for creating Native AOT .NET tools. Somewhat as a proof of concept, I tried applying this to my new sleep-pc tool.
I opted for the "compromise package" approach that I described in my previous post. With this appoach:
- The "root" sleep-pc.nupkg contains a .NET 8 framework-dependent build of the tool, with a pointer to the platform-specific version of the tool for .NET 10 SDK users.
- The packaged application has
<RollForward>Major</RollForward>so that it will run on .NET 9 if the user has that installed and no .NET 8 runtime. - The platform-specific package contains the Native AOT asset only, which is used when the user has .NET 10+ installed.
I discussed this compromise package style a lot in the last post, so here I just show my final .csproj file:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>SleepPc</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!-- NuGet/Tool settings -->
<PackAsTool>true</PackAsTool>
<ToolCommandName>sleep-pc</ToolCommandName>
<PackageId>sleep-pc</PackageId>
<PackageVersion>0.1.0</PackageVersion>
<Authors>Andrew Lock</Authors>
<Description>A tool for sending your windows laptop to sleep after a given time</Description>
<PackageTags>sleep;timer;windows;tool</PackageTags>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<RollForward>Major</RollForward>
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
</PropertyGroup>
<!-- Conditional framework version to support NuGet tool -->
<PropertyGroup Condition="$(RuntimeIdentifier) != ''">
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<PropertyGroup Condition="$(RuntimeIdentifier) == ''">
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
</PropertyGroup>
<PropertyGroup Condition="$(TargetFramework) == 'net10.0'">
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
<PublishAot>true</PublishAot>
<!-- Size optimisation bits for Native AOT -->
<DebuggerSupport>false</DebuggerSupport>
<EventSourceSupport>false</EventSourceSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<MetricsSupport>false</MetricsSupport>
<TrimmerRemoveSymbols>true</TrimmerRemoveSymbols>
<StripSymbols>true</StripSymbols>
<InvariantGlobalization>true</InvariantGlobalization>
<IlcDisableReflection>true</IlcDisableReflection>
<IlcTrimMetadata>true</IlcTrimMetadata>
<IlcOptimizationPreference>Size</IlcOptimizationPreference>
<!-- For analysis by Sizoscope -->
<IlcGenerateMstatFile>true</IlcGenerateMstatFile>
<IlcGenerateDgmlFile>true</IlcGenerateDgmlFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ConsoleAppFramework" Version="5.5.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>
With this project file we can now package the tool as two NuGet packages by running
dotnet pack
dotnet pack -r win-x64
and the resulting packages are pretty decent sizes:

That covers most things, so all that's left is spicing up the console logs!
Improving the console output
Your go-to option whenever you want to have nice looking console output is to use Spectre.Console but, perhaps controversially, I decided to forgo it in this case. My reasoning being that Spectre.Console technically isn't AOT compatible, and there was really only a tiny amount of dynamism that I wanted.
Basically, all I wanted to add was a countdown timer, instead of the app just sitting in a Thread.Sleep() or Task.Wait(). To achieve that I used a trick that I've used in the past to "replace" a line in the console, by sending backspaces in a Console.WriteLine():
var wait = TimeSpan.FromSeconds(sleepDelaySeconds);
// Write the initial text
Console.Write("⏰ Time remaining: ");
// Send a bunch of backspaces followed by the formatted text
Console.Write("\b\b\b\b\b\b\b\b{0:hh\\:mm\\:ss}", wait);
When this is running, it looks like the console is updating in place. It's not perfect, but it does the job well enough for me:

To make the countdown run properly, we have to switch to a loop to make sure we're updating the console regularly. That adds a bit of complexity, particularly given we don't want to "drift" from the deadline, but it's relatively self explanatory:
// Calculate when we should end, to avoid drift
var wait = TimeSpan.FromSeconds(sleepDelaySeconds);
var deadline = DateTimeOffset.UtcNow.Add(wait);
var dryRunText = (dryRun ? " (dry run)" : "");
Console.OutputEncoding = System.Text.Encoding.UTF8;
Console.WriteLine($@"Will sleep after, {wait:hh\:mm\:ss} at {deadline:dd MMM yy HH:mm:ss}{dryRunText}");
Console.WriteLine("Press ctrl-c to cancel");
Console.WriteLine();
Console.Write("⏰ Time remaining: ");
while (!ct.IsCancellationRequested)
{
// Update the clock
Console.Write("\b\b\b\b\b\b\b\b{0:hh\\:mm\\:ss}", wait);
try
{
await Task.Delay(1_000, ct);
}
catch
{
// Canceled by the user (Ctrl+C)
goto canceled;
}
wait = deadline - DateTimeOffset.UtcNow;
if (wait <= TimeSpan.Zero)
{
Console.WriteLine();
Console.WriteLine($"💤 Deadline reached, sleeping{dryRunText}");
if (!dryRun)
{
try
{
SetSuspendState(false, false, false);
}
catch (Exception ex)
{
Console.WriteLine($"⚠️ Error triggering sleep: {ex}");
}
}
return;
}
}
canceled:
Console.WriteLine();
Console.WriteLine("❌ Sleep cancelled");
And that's it!
You can find the full code for the tool on GitHub and the tool on NuGet:

Install it using dotnet tool install -g sleep-pc, and no more failed sleeping!
Summary
In this post I described a small .NET tool that I built to force a Windows PC to go to sleep after a timer expires. I started by showing the the Win32 API I used to send my laptop to sleep, and the small proof of concept app. I then expanded this implementation to Native AOT compile the app, pack it as a .NET tool, and add some improved console output. You can view the code on GitHub or install the tool from Nuget.
