Skip to content

Concurrent SetKeepDisplayAwake and SetRequest lose one change: the request is read and written under separate locks (the #30 race, outside Toggle) #39

Closed

Description

@matt-edmondson

What's wrong

KeepAwakeController.SetKeepDisplayAwake reads the current request through the Request getter, which takes and releases gate, and then passes a modified copy to SetRequest, which takes gate again (NoSleep/KeepAwakeController.cs:132-133, getter at 88-97, SetRequest at 142-168):

public void SetKeepDisplayAwake(bool keepDisplayAwake) =>
    SetRequest(Request with { KeepDisplayAwake = keepDisplayAwake });

The read-modify-write isn't atomic, so concurrent changes to the request can overwrite each other. The class doc (line 15) promises "Every mutation is serialised". #30/#31 fixed exactly this shape for Toggle by moving the decision inside the lock (SetActive(Func<bool,bool>)), but the request path still has it.

Failure scenario

Start with the request { KeepDisplayAwake = false, Reason = default }.

  1. Thread A calls SetKeepDisplayAwake(true) and reads {false, default}.
  2. Thread B calls controller.SetRequest(controller.Request with { Reason = "Rendering" }) and writes {false, "Rendering"}.
  3. Thread A writes {true, default}.

B's reason is lost, yet StateChanged fires for both calls, so a listener believes both took effect. If B wins instead, A's display setting is lost and the screen blanks even though the caller asked for it to stay lit.

In a stress repro built from the repository's sources with a no-op blocker, 4058 of 20000 iterations ended without both changes applied.

The NoSleep tool itself is unlikely to hit this, because --reason is applied at startup and menu clicks arrive on the UI thread. Library consumers that drive the controller from more than one thread will hit it.

Suggested fix

Do what #31 did for Toggle: add an overload, SetRequest(Func<SleepBlockRequest, SleepBlockRequest> update) (public or private), that computes the new request inside lock (gate), and route SetKeepDisplayAwake through it:

public void SetKeepDisplayAwake(bool keepDisplayAwake) =>
    SetRequest(current => current with { KeepDisplayAwake = keepDisplayAwake });

If the overload is public, callers can make their own atomic edits, such as changing only the reason.

Acceptance criteria

Activity

  1. matt-edmondson commented on Nov 2, 2026

    @matt-edmondson
    CollaboratorAuthor

    Triage


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions