From 160d2e0b1f6255e8fabc31a7f47524eb2847ca05 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:30:56 +0000 Subject: [PATCH 1/2] Lock SetActiveProfile and DeleteProfile so a delete cannot leave a stale active id [patch] SetActiveProfile checked the profile existed and then wrote the active id as two steps. A DeleteProfile landing between them removed the profile before the id was set, so its clear was skipped and the active id kept naming the deleted profile; a profile later recreated with that id became active on its own. Run the check-and-set and the remove-and-clear under the manager's lock. Fixes #120 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WAdbE4VMmkBh1tzMGpC9Wy --- .../ProfileActivateDeleteConcurrencyTests.cs | 54 +++++++++++++++++++ Keybinding/Services/ProfileManager.cs | 32 +++++++---- 2 files changed, 75 insertions(+), 11 deletions(-) create mode 100644 Keybinding.Test/ProfileActivateDeleteConcurrencyTests.cs diff --git a/Keybinding.Test/ProfileActivateDeleteConcurrencyTests.cs b/Keybinding.Test/ProfileActivateDeleteConcurrencyTests.cs new file mode 100644 index 0000000..3c50645 --- /dev/null +++ b/Keybinding.Test/ProfileActivateDeleteConcurrencyTests.cs @@ -0,0 +1,54 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Keybinding.Test; + +using ktsu.Keybinding.Core.Services; + +[TestClass] +public class ProfileActivateDeleteConcurrencyTests +{ + private const int Trials = 10000; + + public TestContext TestContext { get; set; } = null!; + + [TestMethod] + public void SetActiveProfile_RacingDeleteProfile_NeverLeavesTheDeletedIdActive() + { + int stale = 0; + for (int trial = 0; trial < Trials; trial++) + { + ProfileManager profiles = new(); + profiles.CreateProfile("x", "X"); + using Barrier barrier = new(2); + + Thread activate = new(() => + { + barrier.SignalAndWait(TestContext.CancellationToken); + + // Keep activating until the delete lands, so the activation is in flight when it does + while (profiles.SetActiveProfile("x")) + { + } + }); + Thread delete = new(() => + { + barrier.SignalAndWait(TestContext.CancellationToken); + profiles.DeleteProfile("x"); + }); + + activate.Start(); + delete.Start(); + activate.Join(); + delete.Join(); + + // A profile recreated with the deleted id must not become active on its own + profiles.CreateProfile("x", "Recreated"); + if (profiles.GetActiveProfile() is not null) + { + stale++; + } + } + + Assert.AreEqual(0, stale, $"{stale} of {Trials} trials left the active id pointing at the deleted profile."); + } +} diff --git a/Keybinding/Services/ProfileManager.cs b/Keybinding/Services/ProfileManager.cs index 923182d..d9df93a 100644 --- a/Keybinding/Services/ProfileManager.cs +++ b/Keybinding/Services/ProfileManager.cs @@ -57,15 +57,22 @@ public bool DeleteProfile(string profileId) } string normalizedId = profileId.Trim(); - bool removed = _profiles.TryRemove(normalizedId, out _); - // Clear active profile if it was the one being deleted - if (removed && _activeProfileId == normalizedId) + // Remove and clear under the lock SetActiveProfile checks and sets under, so an activation + // cannot land between them and leave the active id naming the deleted profile + // (ktsu-dev/Keybinding#120) + lock (_lock) { - _activeProfileId = null; - } + bool removed = _profiles.TryRemove(normalizedId, out _); - return removed; + // Clear active profile if it was the one being deleted + if (removed && _activeProfileId == normalizedId) + { + _activeProfileId = null; + } + + return removed; + } } /// @@ -102,13 +109,16 @@ public bool SetActiveProfile(string profileId) string normalizedId = profileId.Trim(); - if (!_profiles.ContainsKey(normalizedId)) + lock (_lock) { - return false; - } + if (!_profiles.ContainsKey(normalizedId)) + { + return false; + } - _activeProfileId = normalizedId; - return true; + _activeProfileId = normalizedId; + return true; + } } /// From a4ac0c66e1e0733691ffebb6b17f26f7affd0ea9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:36:34 +0000 Subject: [PATCH 2/2] Comment the intentionally empty activation loop in the race test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WAdbE4VMmkBh1tzMGpC9Wy --- Keybinding.Test/ProfileActivateDeleteConcurrencyTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Keybinding.Test/ProfileActivateDeleteConcurrencyTests.cs b/Keybinding.Test/ProfileActivateDeleteConcurrencyTests.cs index 3c50645..f8f276f 100644 --- a/Keybinding.Test/ProfileActivateDeleteConcurrencyTests.cs +++ b/Keybinding.Test/ProfileActivateDeleteConcurrencyTests.cs @@ -28,6 +28,7 @@ public void SetActiveProfile_RacingDeleteProfile_NeverLeavesTheDeletedIdActive() // Keep activating until the delete lands, so the activation is in flight when it does while (profiles.SetActiveProfile("x")) { + // Intentionally empty: each iteration is another activation racing the delete } }); Thread delete = new(() =>