Skip to content

fix: serialize Gaming Profile crash-recovery on the service gate#1436

Merged
laurentiu021 merged 1 commit into
mainfrom
fix/gaming-recovery-gate
Jul 11, 2026
Merged

fix: serialize Gaming Profile crash-recovery on the service gate#1436
laurentiu021 merged 1 commit into
mainfrom
fix/gaming-recovery-gate

Conversation

@laurentiu021

Copy link
Copy Markdown
Owner

Problem (P2 — startup race, non-destructive but user-visible)

On launch, if a previous run closed/crashed with game-mode tweaks still applied, the Gaming Profile tab offers to revert them via GamingProfileService.RecoverPendingAsync. Unlike ApplyAsync and RevertAsync — which both serialize on the service's SemaphoreSlim (_gate) — RecoverPendingAsync ran its RunRevertAsync and its LoadStoreSaveStore completely ungated.

After the user answers the "restore leftover changes?" dialog, the UI is live again. Clicking Start then launches ApplyAsync concurrently with the still-running recovery revert:

  • Two paths reverting/applying the same machine-wide tweaks (power plan, visual effects, search indexing, notifications) at once.
  • An unsynchronized read-modify-write of the on-disk store: recovery's LoadStore … SaveStore(store with { ActiveSession = null }) can interleave with Apply's SaveStore(… with { ActiveSession = <new> }), losing one write. If the ActiveSession = null clear is the one lost, the leftover marker resurrects and the app re-prompts for recovery on every launch.

Fix

RecoverPendingAsync now acquires _gate for its whole body, mirroring RevertAsync:

  • Serializes against ApplyAsync / RevertAsync (a concurrent Start now waits for recovery to finish, and vice-versa).
  • Re-reads the store inside the gate so the read-modify-write is atomic against a concurrent SaveStore.
  • Uses ConfigureAwait(false) under the gate for the same anti-deadlock reason the other two do (the gate-release continuation must stay off the UI thread so Dispose's shutdown _gate.Wait() can't deadlock against it).

All three gate consumers (ApplyAsync, RevertAsync, RecoverPendingAsync) are now consistent.

Test

RecoverPendingAsync_WaitsForGate_WhenARevertHoldsIt — reuses the file's existing deterministic seam (GatedRevertTweak, which suspends on a signal the test controls — no wall-clock): with a revert suspended holding _gate, RecoverPendingAsync()'s returned Task is asserted not completed (it's parked on _gate.WaitAsync); after Resume(), both complete and the ActiveSession marker is cleared exactly once. The pending session uses an all-false profile so recovery's own revert is an inert no-op (no power/registry/service call) — fully deterministic, touches nothing on the machine.

Red→green: pre-fix (ungated) RecoverPendingAsync completes immediately even while the revert holds the gate, so Assert.False(recover.IsCompleted) fails; post-fix it blocks on the gate → passes. (dotnet test not run locally per repo policy; verified via CI.)

Regression sweep

  • dotnet build main + tests: 0 warnings / 0 errors.
  • Leak-term + debug-code scan on both touched files: clean.
  • Author headers intact; version bumped 1.52.98 → 1.52.99; CHANGELOG entry in this PR.

On launch, if a previous run closed with game-mode tweaks still applied, the tab
offers to revert them via RecoverPendingAsync. Unlike ApplyAsync and RevertAsync
(both serialize on the service SemaphoreSlim _gate), RecoverPendingAsync ran its
RunRevertAsync and its LoadStore->SaveStore completely ungated. After the user
answers the "restore?" dialog the UI is live again, so clicking Start launches
ApplyAsync concurrently with the still-running recovery revert: two paths
reverting/applying the same machine-wide tweaks and doing an unsynchronized
read-modify-write of the on-disk store -- which can lose the ActiveSession=null
clear (resurrecting the leftover marker so it re-prompts forever) or interleave
conflicting tweak steps.

RecoverPendingAsync now acquires _gate for its whole body (mirroring RevertAsync,
including the ConfigureAwait(false) that keeps the gate-release continuation off
the UI thread so Dispose's shutdown _gate.Wait() can't deadlock) and re-reads the
store inside the gate so the read-modify-write is atomic against a concurrent
SaveStore.

Regression test: with a revert suspended holding the gate (via the existing
GatedRevertTweak seam), RecoverPendingAsync's returned Task is deterministically
NOT completed until the gate is released -- proving it serializes on _gate. Pins
the marker is cleared exactly once after recovery.
@laurentiu021 laurentiu021 merged commit 8cd3e29 into main Jul 11, 2026
4 checks passed
@laurentiu021 laurentiu021 deleted the fix/gaming-recovery-gate branch July 11, 2026 00:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant