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

@@ -22,20 +22,35 @@ public sealed class AppStateService : IAppStateService
_persistence = persistence;
_appInfoService = appInfoService;
_filePath = StateJsonPath();
State = _persistence.Load(_filePath, JsonSerializationContext.Default.AppStateModel);
_state = _persistence.Load(_filePath, JsonSerializationContext.Default.AppStateModel);
}
private AppStateModel _state;
/// <inheritdoc/>
public AppStateModel State { get; private set; }
public AppStateModel State => Volatile.Read(ref _state);
/// <inheritdoc/>
public event TypedEventHandler<IAppStateService, AppStateModel>? StateChanged;
/// <inheritdoc/>
public void Save()
public void Save() => UpdateState(s => s);
/// <inheritdoc/>
public void UpdateState(Func<AppStateModel, AppStateModel> transform)
{
_persistence.Save(State, _filePath, JsonSerializationContext.Default.AppStateModel);
StateChanged?.Invoke(this, State);
AppStateModel snapshot;
AppStateModel updated;
do
{
snapshot = Volatile.Read(ref _state);
updated = transform(snapshot);
}
while (Interlocked.CompareExchange(ref _state, updated, snapshot) != snapshot);
var newState = Volatile.Read(ref _state);
_persistence.Save(newState, _filePath, JsonSerializationContext.Default.AppStateModel);
StateChanged?.Invoke(this, newState);
}
private string StateJsonPath()

View File

@@ -21,6 +21,12 @@ public interface IAppStateService
/// </summary>
void Save();
/// <summary>
/// Atomically applies a transformation to the current state, persists the result,
/// and raises <see cref="StateChanged"/>.
/// </summary>
void UpdateState(Func<AppStateModel, AppStateModel> transform);
/// <summary>
/// Raised after state has been saved to disk.
/// </summary>

View File

@@ -22,6 +22,12 @@ public interface ISettingsService
/// <param name="hotReload">When <see langword="true"/>, raises <see cref="SettingsChanged"/> after saving.</param>
void Save(bool hotReload = true);
/// <summary>
/// Atomically applies a transformation to the current settings, persists the result,
/// and optionally raises <see cref="SettingsChanged"/>.
/// </summary>
void UpdateSettings(Func<SettingsModel, SettingsModel> transform, bool hotReload = true);
/// <summary>
/// Raised after settings are saved with <paramref name="hotReload"/> enabled, or after <see cref="Reload"/>.
/// </summary>

View File

@@ -29,27 +29,38 @@ public sealed class SettingsService : ISettingsService
_persistence = persistence;
_appInfoService = appInfoService;
_filePath = SettingsJsonPath();
Settings = _persistence.Load(_filePath, JsonSerializationContext.Default.SettingsModel);
_settings = _persistence.Load(_filePath, JsonSerializationContext.Default.SettingsModel);
ApplyMigrations();
}
private SettingsModel _settings;
/// <inheritdoc/>
public SettingsModel Settings { get; private set; }
public SettingsModel Settings => Volatile.Read(ref _settings);
/// <inheritdoc/>
public event TypedEventHandler<ISettingsService, SettingsModel>? SettingsChanged;
/// <inheritdoc/>
public void Save(bool hotReload = true)
{
_persistence.Save(
Settings,
_filePath,
JsonSerializationContext.Default.SettingsModel);
public void Save(bool hotReload = true) => UpdateSettings(s => s, hotReload);
/// <inheritdoc/>
public void UpdateSettings(Func<SettingsModel, SettingsModel> transform, bool hotReload = true)
{
SettingsModel snapshot;
SettingsModel updated;
do
{
snapshot = Volatile.Read(ref _settings);
updated = transform(snapshot);
}
while (Interlocked.CompareExchange(ref _settings, updated, snapshot) != snapshot);
var newSettings = Volatile.Read(ref _settings);
_persistence.Save(newSettings, _filePath, JsonSerializationContext.Default.SettingsModel);
if (hotReload)
{
SettingsChanged?.Invoke(this, Settings);
SettingsChanged?.Invoke(this, newSettings);
}
}
@@ -71,10 +82,10 @@ public sealed class SettingsService : ISettingsService
migratedAny |= TryMigrate(
"Migration #1: HotkeyGoesHome (bool) -> AutoGoHomeInterval (TimeSpan)",
root,
Settings,
ref _settings,
nameof(SettingsModel.AutoGoHomeInterval),
DeprecatedHotkeyGoesHomeKey,
(model, goesHome) => model.AutoGoHomeInterval = goesHome ? TimeSpan.Zero : Timeout.InfiniteTimeSpan,
(ref SettingsModel model, bool goesHome) => model = model with { AutoGoHomeInterval = goesHome ? TimeSpan.Zero : Timeout.InfiniteTimeSpan },
JsonSerializationContext.Default.Boolean);
}
}
@@ -89,13 +100,15 @@ public sealed class SettingsService : ISettingsService
}
}
private delegate void MigrationApply<T>(ref SettingsModel model, T value);
private static bool TryMigrate<T>(
string migrationName,
JsonObject root,
SettingsModel model,
ref SettingsModel model,
string newKey,
string oldKey,
Action<SettingsModel, T> apply,
MigrationApply<T> apply,
JsonTypeInfo<T> jsonTypeInfo)
{
try
@@ -108,7 +121,7 @@ public sealed class SettingsService : ISettingsService
if (root.TryGetPropertyValue(oldKey, out var oldNode) && oldNode is not null)
{
var value = oldNode.Deserialize(jsonTypeInfo);
apply(model, value!);
apply(ref model, value!);
return true;
}
}