CmdPal: Make settings and app state immutable (#46451)

## Summary
 
 This PR refactors CmdPal settings/state to be immutable end-to-end.
 
 ### Core changes
 - Convert model types to immutable records / init-only properties:
   - `SettingsModel`
   - `AppStateModel`
   - `ProviderSettings`
   - `DockSettings`
   - `RecentCommandsManager`
- supporting settings types (fallback/hotkey/alias/top-level
hotkey/history items, etc.)
- Replace mutable collections with immutable equivalents where
appropriate:
   - `ImmutableDictionary<,>`
   - `ImmutableList<>`
 - Move mutation flow to atomic service updates:
- `ISettingsService.UpdateSettings(Func<SettingsModel, SettingsModel>)`
   - `IAppStateService.UpdateState(Func<AppStateModel, AppStateModel>)`
- Update ViewModels/managers/services to use copy-on-write (`with`)
patterns instead of in-place
mutation.
- Update serialization context + tests for immutable model graph
compatibility.
 
 ## Why
 
Issue #46437 is caused by mutable shared state being updated from
different execution paths/threads,
leading to race-prone behavior during persistence/serialization.
 
By making settings/app state immutable and using atomic swap/update
patterns, we remove in-place
mutation and eliminate this class of concurrency bug.
 
 ## Validation
 
 - Built successfully:
   - `Microsoft.CmdPal.UI.ViewModels`
   - `Microsoft.CmdPal.UI`
   - `Microsoft.CmdPal.UI.ViewModels.UnitTests`
 - Updated unit tests for immutable update patterns.
 
 Fixes #46437

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Michael Jolley
2026-03-27 12:54:58 -05:00
committed by GitHub
parent ed47bceac2
commit 4337f8e5ff
34 changed files with 891 additions and 578 deletions

View File

@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Services;
@@ -41,7 +42,7 @@ public class AppStateServiceTests
// Arrange
var expectedState = new AppStateModel
{
RunHistory = new List<string> { "command1", "command2" },
RunHistory = ImmutableList.Create("command1", "command2"),
};
_mockPersistence
.Setup(p => p.Load(
@@ -86,7 +87,8 @@ public class AppStateServiceTests
{
// Arrange
var service = new AppStateService(_mockPersistence.Object, _mockAppInfo.Object);
service.State.RunHistory.Add("test-command");
service.UpdateState(s => s with { RunHistory = s.RunHistory.Add("test-command") });
_mockPersistence.Invocations.Clear(); // Reset after Arrange — UpdateState also persists
// Act
service.Save();
@@ -160,4 +162,44 @@ public class AppStateServiceTests
// Assert
Assert.AreEqual(2, eventCount);
}
[TestMethod]
public void UpdateState_ConcurrentUpdates_NoLostUpdates()
{
// Arrange — two threads each add items to RunHistory concurrently.
// With the CAS loop, every add must land (no lost updates).
var service = new AppStateService(_mockPersistence.Object, _mockAppInfo.Object);
const int iterations = 50;
var barrier = new System.Threading.Barrier(2);
// Act
var t1 = System.Threading.Tasks.Task.Run(() =>
{
barrier.SignalAndWait();
for (var i = 0; i < iterations; i++)
{
service.UpdateState(s => s with { RunHistory = s.RunHistory.Add($"t1-{i}") });
}
});
var t2 = System.Threading.Tasks.Task.Run(() =>
{
barrier.SignalAndWait();
for (var i = 0; i < iterations; i++)
{
service.UpdateState(s => s with { RunHistory = s.RunHistory.Add($"t2-{i}") });
}
});
System.Threading.Tasks.Task.WaitAll(t1, t2);
// Assert — all 100 items must be present (no lost updates)
Assert.AreEqual(iterations * 2, service.State.RunHistory.Count, "All concurrent updates should be preserved");
for (var i = 0; i < iterations; i++)
{
Assert.IsTrue(service.State.RunHistory.Contains($"t1-{i}"), $"Missing t1-{i}");
Assert.IsTrue(service.State.RunHistory.Contains($"t2-{i}"), $"Missing t2-{i}");
}
}
}