Skip to content

[Awake] Timed keep-awake may show incorrect time remaining in the system tray #41671

@daverayment

Description

@daverayment

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

Metadata

Metadata

Labels

Cost-SmallSmall work item - 0-8 hours of workIssue-BugSomething isn't workingProduct-AwakeIssues regarding the PowerToys Awake utility

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions