-
Notifications
You must be signed in to change notification settings - Fork 7.3k
Description
Microsoft PowerToys version
0.94
Installation method
Dev build in Visual Studio
Area(s) with issue?
Awake
Steps to reproduce
The bug
In timed keep-awake mode, the remaining time displayed in the system tray icon can become progressively desynchronised from the actual expiration time. This is especially noticeable under heavy system load.
This can lead to a confusing user experience where the keep-awake period ends abruptly while the tray icon still indicated that there was time left.
The root cause is that the countdown relies on counting ticks from an Observable.Interval
, while the actual timer expiry is handled by a separate Observable.Timer
. The Interval
ticks can be delayed under load, suffering from cumulative errors, but the Observable.Timer
will be accurate, causing the two to drift apart.
The code in question is within SetTimedKeepAwake()
in Manager.cs
.
Why it happens
The Observable.Interval
used for the UI updates relies on the ThreadPool scheduler, which does not guarantee real-time precision. Each second-long tick may be affected by:
- OS timer resolution and timer coalescing.
- Thread scheduler latency. The timer's callback may have to wait if there are higher-priority tasks or if the CPU is heavily loaded.
- ThreadPool starvation.
- Garbage collection pauses. A GC can temporarily pause all managed threads, causing delays.
The key issue is that these delays are cumulative for the UI counter, which simply counts the number of elapsed ticks, assuming that each one was exactly a second long.
Minimal repro to show UI timer drift
This console application illustrates the underlying problem. It shows that the tick-counting mechanism used for the UI is susceptible to significant cumulative drift. The Stopwatch
represents the real elapsed time, analogous to the Observable.Timer
that controls the actual expiry in Awake's code.
using System.Diagnostics;
using System.Reactive.Linq;
namespace TimerDrift;
public static class Program
{
private const int TestDurationInMinutes = 5;
public static void Main()
{
var totalTicks = TestDurationInMinutes * 60;
Console.WriteLine($"Timer drift test under CPU load:");
Console.WriteLine($"Starting test for {TestDurationInMinutes} minutes ({totalTicks} seconds).\n");
// Load the CPU with a background task.
var cts = new CancellationTokenSource();
var stressTask = RunCpuStressTest(cts.Token);
// This observable simulates Awake's timing logic.
// It runs for a fixed number of ticks and then completes.
var flawedTimer = Observable.Interval(TimeSpan.FromSeconds(1))
.Take(totalTicks);
// Use a high-precision Stopwatch for the "ground truth" measurement.
var stopwatch = Stopwatch.StartNew();
// Wait on the completion of the Observable timer.
flawedTimer.Wait();
stopwatch.Stop();
// Clean up the CPU stress task.
cts.Cancel();
try
{
stressTask.Wait();
}
catch (OperationCanceledException)
{
}
// Results.
var countedDuration = TimeSpan.FromSeconds(totalTicks);
var actualDuration = stopwatch.Elapsed;
var drift = actualDuration - countedDuration;
Console.WriteLine();
Console.WriteLine("Test Complete\n");
Console.WriteLine($" Duration via Tick Counting: {countedDuration:mm\\:ss\\.fff}");
Console.WriteLine($" Actual Elapsed Time (Stopwatch): {actualDuration:mm\\:ss\\.fff}");
Console.WriteLine("-----------------------------------------------");
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($" => Cumulative Drift: {drift.TotalMilliseconds:F0} ms");
Console.ResetColor();
Console.WriteLine("-----------------------------------------------\n");
Console.WriteLine("Press any key to exit...");
Console.ReadKey();
}
private static Task RunCpuStressTest(CancellationToken token)
{
return Task.Run(() =>
{
Console.WriteLine("Starting CPU stress...");
Parallel.For(0, Environment.ProcessorCount, _ =>
{
while (!token.IsCancellationRequested)
{
// Spin.
}
});
Console.WriteLine("CPU stress stopped.");
}, token);
}
}
Sample output from my machine
Test Complete
Duration via Tick Counting: 05:00.000
Actual Elapsed Time (Stopwatch): 05:12.702
-----------------------------------------------
=> Cumulative Drift: 12702 ms
-----------------------------------------------
(Core i5-1135G7 laptop @2.40 GHz)
This equates to drift of more than two and a half minutes over an hour.
Proposed solution
Adopt logic based on a single fixed target expiration time instead of counting seconds, unifying the source of truth for both the UI display and the timer's completion.
var targetExpiryTime = DateTimeOffset.Now.AddSeconds(seconds);
Observable.Interval(TimeSpan.FromSeconds(1))
.Select(_ => targetExpiryTime - DateTimeOffset.Now)
.TakeWhile(remaining => remaining.TotalSeconds > 0)
.Subscribe(
remainingTimeSpan =>
{
// Update systray
},
() => // (OnCompleted handler)
{
// Timer has expired...
},
// ...
Benefits of this approach
- The system tray will always show an accurate countdown because it recalculates the remaining time against a fixed expiry time on every tick.
- The logic is more direct and easier to understand than the combined timer solution currently in place.
- It removes the need for the multiplication into milliseconds and the subsequent check against
uint.MaxValue
, allowing the timer to handle durations greater than the current ~49 day limit. If there are users who want to keep their systems awake for the next 136 years, they should then be able to 😉
✔️ Expected Behavior
The system tray icon should provide an accurate countdown of the time remaining. When the timer expires, the keep-awake function should stop, and the icon should update to reflect this.
❌ Actual Behavior
The countdown in the system tray can lag behind the actual time remaining, especially under load. This causes the keep-awake mode to terminate while the tray icon incorrectly shows time remaining, creating a jarring user experience.
Additional Information
No response
Other Software
No response