diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 75a77055..4713e7a0 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -281,7 +281,8 @@ to scroll.
a scale from 70% (or lower) to just above 100%, so a small loss is not drawn as a cliff. The same paint lifecycle
as `SpeedTrendChart`.
- `UninstallerViewModel` — winget-based app uninstaller with batch support, and the Left behind card
- that follows an uninstall.
+ that follows an uninstall. Cancel and Escape stop a batch before its next app: the uninstaller already running
+ has a token of its own, which only `Dispose` cancels (#2834).
- `PerformanceViewModel` — per-tweak performance tuning with snapshot restore. Refresh is off while `IsBusy`,
and a read leaves `NeedsReboot` alone, so a graphics change's reboot notice lasts the session (#2607).
- `PingViewModel` — live ping monitoring with latency chart and health verdict.
@@ -370,7 +371,7 @@ to scroll.
`LogsArrival.AppProblems` of the period chosen (#2887). Read-only.
- `RestorePointsViewModel` — list, create, and restore Windows System Restore points (admin for all three, since Windows refuses a standard user the list, which the empty state says rather than reporting none; restore reboots, gated by confirmation).
- `LegacyPanelsViewModel` — one-click launcher for the fixed catalog of classic Windows applets (pure launchers, no system modification).
-- `SystemFixesViewModel` — consolidated one-click repairs (SFC, DISM `/RestoreHealth`, Windows Update reset, WinGet reinstall, Restart Explorer, Rebuild icon & thumbnail cache) with per-fix confirmation + live output; opens netplwiz for secure auto-logon. The network-stack resets live on Network Repair.
+- `SystemFixesViewModel` — consolidated one-click repairs (SFC, DISM `/RestoreHealth`, Windows Update reset, WinGet reinstall, Restart Explorer, Rebuild icon & thumbnail cache) with per-fix confirmation + live output; opens netplwiz for secure auto-logon. The network-stack resets live on Network Repair. It overrides no `EscapeCancel`: every run changes Windows, so Escape stops none of them and the Cancel button does (#2834).
- `BootAnalyzerViewModel` — read-only boot-time history + slow-component breakdown from the Diagnostics-Performance log, with a trend vs recent average; needs admin to read the log.
- `TimerResolutionViewModel` — request the finest Windows timer resolution (≈0.5 ms) for lower game input latency, or release it; shows the live effective value.
- `FileLockViewModel` — find which processes are holding a file/folder (Restart Manager) and optionally end a selected one after confirmation; critical processes are protected. A failed check and a path that does not exist are reported as such, never as "no process". End process passes the locker's start time with its ID, so a locker that closed and whose ID Windows gave to another program is reported as already closed rather than that program ended (#2514); an ended or closed locker is followed by a fresh check, with the outcome put in front of its result.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8269f261..fe76de04 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,19 @@ That paragraph is not decoration: the release workflow copies each entry verbati
the GitHub release body and the announcement discussion, so it is the first thing a
prospective user reads. CI fails a pull request whose newest entry is missing it.
+## [1.130.52] - 2026-10-21
+
+**Escape no longer stops an uninstall or a System Fixes repair half-way, and Uninstaller's Cancel stops a batch
+before its next app instead of ending the uninstaller that is running.**
+
+### Fixed
+
+- **Uninstaller's Cancel, and Escape, ended the running uninstaller half-way.** That can leave a program half-removed.
+ They now stop the batch before its next app, once the uninstaller already running has finished, and the status line
+ says so. Closing SysManager still ends it, after asking. Since 0.11.0 for Cancel and 1.76.0 for Escape.
+- **Escape stopped a System Fixes repair half-way.** SFC, DISM and the scripted repairs were ended by a bare
+ keypress. Escape stops nothing on System Fixes now; its Cancel button still stops a repair. Since 1.76.0.
+
## [1.130.51] - 2026-10-21
**Windows Features says why Windows refused a change, shows each feature the way Windows reports it afterwards, and
diff --git a/README.md b/README.md
index e5eafeab..bf5a1f24 100644
--- a/README.md
+++ b/README.md
@@ -231,8 +231,11 @@ on, the way it opens a folder in File Explorer. Opening the appearance panel mov
cycles the themes inside it, and `Escape` closes it and puts focus back on the button you opened it from.
`Escape` also stops whatever the open tab is doing — a disk scan, a duplicate search, a speed test — on
-the 26 tabs that can be cancelled from the keyboard: every tab with a Cancel button, and Display Profiles, where
-it puts back the previous mode while a new one waits for "Keep". It only acts
+the 25 tabs that can be cancelled from the keyboard: every tab with a Cancel button but System Fixes, and Display
+Profiles, where it puts back the previous mode while a new one waits for "Keep". Every run on System Fixes repairs
+Windows, so stopping one is left to its Cancel button rather than a keypress. On Windows Features `Escape` stops a
+scan, never a change, and on Uninstaller it stops a batch before its next app, once the uninstaller already running
+has finished. It only acts
while something is running, and only after the control you are on has had its own chance to use the key,
so it still closes a drop-down or undoes a text edit first.
@@ -688,6 +691,7 @@ not need administrator rights to open it.
Across the tab:
- Live output, honest success/failure reporting, admin elevation banner
+- The Cancel button stops a repair; `Escape` does not, because every run here changes Windows
- *Network-stack reset (Winsock / TCP-IP / DNS flush) lives on the Network → Network
Repair tab, which offers those as individual one-click tools.*
- *Freeing up disk space lives on Cleanup → Quick Cleanup. The two tabs are split by what
@@ -1755,6 +1759,8 @@ offers, "rate us" prompts:
to a second copy of themselves and return straight away; those apps stay on the list, marked as
still installed, until the uninstaller finishes and you scan again
- Runs uninstall actions only from an unelevated SysManager session; each package requests its own UAC elevation when required
+- **Cancel stops a batch between apps** — the uninstaller already running finishes first, because ending one
+ half-way can leave its program half-removed. `Escape` does the same
- **What it left behind** — after an uninstall, a Left behind card lists what the app left on
the PC: the folder it was installed in, folders named after it in AppData, Local AppData and
ProgramData, its own settings key under `HKEY_CURRENT_USER\Software`, and a folder named after
diff --git a/SysManager/SysManager.Tests/ArchitectureTests.cs b/SysManager/SysManager.Tests/ArchitectureTests.cs
index c06b4c1a..666f265e 100644
--- a/SysManager/SysManager.Tests/ArchitectureTests.cs
+++ b/SysManager/SysManager.Tests/ArchitectureTests.cs
@@ -4039,6 +4039,11 @@ public void EveryPersistedStateFile_IsEitherInAProfileOrDeliberatelyNot()
/// skipped nine tabs and Escape did nothing on them (#2597). For such a button the override must name its
/// command and return null when nothing runs, gated on the flag the command's own
/// [RelayCommand(CanExecute = …)] names where it names one.
+ /// A tab on which every run changes Windows declines Escape, by name below and with the reason.
+ /// Escape is a bare keypress, and on System Fixes it stopped SFC, DISM and the scripted repairs half-way (#2834).
+ /// Its Cancel button stays, for a stop the user means, and its view model overrides no EscapeCancel, or one
+ /// that returns null outright. A name whose view has no Cancel button any more fails, so the list cannot go
+ /// stale.
///
[Fact]
public void EveryCancellableTab_LetsEscapeReachItsOwnCancelCommand()
@@ -4047,6 +4052,13 @@ public void EveryCancellableTab_LetsEscapeReachItsOwnCancelCommand()
var presentation = XNamespace.Get("http://schemas.microsoft.com/winfx/2006/xaml/presentation");
var binding = new Regex(@"^\{Binding\s+(?\w+)", RegexOptions.CultureInvariant);
+ // Views on which every run changes Windows, so Escape stops none of them.
+ var declinesEscape = new Dictionary(StringComparer.Ordinal)
+ {
+ ["SystemFixesView"] = "every repair on it changes Windows; Escape stopped SFC and DISM half-way (#2834)",
+ };
+ var declined = new HashSet(StringComparer.Ordinal);
+
var offenders = new List();
var pairs = 0;
var alwaysShown = 0;
@@ -4085,6 +4097,18 @@ public void EveryCancellableTab_LetsEscapeReachItsOwnCancelCommand()
.Where(l => !l.TrimStart().StartsWith("//", StringComparison.Ordinal)));
var overrideAt = vm.IndexOf("override IRelayCommand? EscapeCancel", StringComparison.Ordinal);
+ if (declinesEscape.ContainsKey(viewName))
+ {
+ declined.Add(viewName);
+ // No override, or one that returns null outright: either way Escape reaches nothing here.
+ var declining = overrideAt < 0 ? "EscapeCancel=>null" : string.Concat(
+ vm[overrideAt..vm.IndexOf(';', overrideAt)].Where(c => !char.IsWhiteSpace(c)));
+ if (!declining.EndsWith("EscapeCancel=>null", StringComparison.Ordinal))
+ offenders.Add($"{viewName}Model is listed as declining Escape, and its EscapeCancel returns "
+ + "a command");
+ continue;
+ }
+
if (overrideAt < 0)
{
offenders.Add($"{viewName}Model has no EscapeCancel override, so Escape does nothing "
@@ -4123,6 +4147,10 @@ public void EveryCancellableTab_LetsEscapeReachItsOwnCancelCommand()
}
}
+ foreach (var stale in declinesEscape.Keys.Where(k => !declined.Contains(k)))
+ offenders.Add($"{stale} is listed as declining Escape but has no Cancel button any more; "
+ + "take it off the list");
+
// Vacuity floors: seventeen Cancel buttons carry a visibility flag today and eleven are on screen all the
// time. A parse that stopped finding either kind would report success having checked nothing.
Assert.True(pairs >= 12,
diff --git a/SysManager/SysManager.Tests/SystemFixesViewModelTests.cs b/SysManager/SysManager.Tests/SystemFixesViewModelTests.cs
index 6b71b334..281b4973 100644
--- a/SysManager/SysManager.Tests/SystemFixesViewModelTests.cs
+++ b/SysManager/SysManager.Tests/SystemFixesViewModelTests.cs
@@ -299,6 +299,40 @@ await runner.Received(1).RunProcessAsync(
"sfc.exe", "/scannow", Arg.Any(), Arg.Any());
}
+ [Fact]
+ public async Task Escape_StopsNoRepair_WhileTheCancelButtonStillDoes()
+ {
+ // Escape ended SFC half-way from a bare keypress (#2834). The Cancel button is the way to stop a repair.
+ using var elevated = AdminHelper.ForceElevation(true);
+ using var dialog = new DialogAnswer(confirm: true);
+ var running = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ CancellationToken sfcToken = default;
+ var runner = Substitute.For();
+ runner.RunProcessAsync("sfc.exe", "/scannow", Arg.Any(), Arg.Any())
+ .Returns(call =>
+ {
+ sfcToken = call.ArgAt(2);
+ sfcToken.Register(() => running.TrySetCanceled(sfcToken));
+ return running.Task;
+ });
+ var vm = NewVm(runner);
+
+ var sfc = vm.RunSfcCommand.ExecuteAsync(null);
+ try
+ {
+ Assert.True(vm.IsBusy);
+ Assert.Null(vm.EscapeCancel);
+
+ vm.CancelCommand.Execute(null);
+ Assert.True(sfcToken.IsCancellationRequested);
+ }
+ finally
+ {
+ running.TrySetResult(0);
+ await sfc;
+ }
+ }
+
[Fact]
public async Task RunSfc_ReadsSfcsOwnOutput_ForItsProgressAndItsVerdict()
{
diff --git a/SysManager/SysManager.Tests/UninstallerViewModelTests.cs b/SysManager/SysManager.Tests/UninstallerViewModelTests.cs
index 6c1c2264..ae240aa9 100644
--- a/SysManager/SysManager.Tests/UninstallerViewModelTests.cs
+++ b/SysManager/SysManager.Tests/UninstallerViewModelTests.cs
@@ -379,12 +379,15 @@ public async Task UninstallSelected_WhenRestartIsRequired_ReportsSuccessfulRemov
}
[Fact]
- public async Task UninstallSelected_WhenCancelled_StopsBatchAndReportsPartialProgress()
+ public async Task UninstallSelected_WhenCancelled_LetsTheRunningUninstallerFinish_ThenStopsBeforeTheNextApp()
{
+ // Cancel, and Escape with it, ended the running uninstaller part-way, which can leave its program half-removed
+ // (#2834). The second of three is running when Cancel is pressed: it finishes, and the third never starts.
var started = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
var pending = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
+ CancellationToken runningToken = default;
var callCount = 0;
var runner = Substitute.For();
runner.RunProcessAsync(
@@ -394,16 +397,15 @@ public async Task UninstallSelected_WhenCancelled_StopsBatchAndReportsPartialPro
Arg.Any())
.Returns(callInfo =>
{
- if (Interlocked.Increment(ref callCount) == 1)
+ if (Interlocked.Increment(ref callCount) != 2)
return Task.FromResult(0);
- var token = callInfo.ArgAt(2);
- token.Register(() => pending.TrySetCanceled(token));
+ runningToken = callInfo.ArgAt(2);
started.TrySetResult(true);
return pending.Task;
});
var vm = NewVm(runner);
- Seed(vm, 2);
+ Seed(vm, 3);
foreach (var app in vm.FilteredApps)
{
app.Source = "winget";
@@ -414,17 +416,22 @@ public async Task UninstallSelected_WhenCancelled_StopsBatchAndReportsPartialPro
var dialog = Substitute.For();
dialog.Confirm(Arg.Any(), Arg.Any()).Returns(true);
DialogService.Instance = dialog;
+ var execution = Task.CompletedTask;
try
{
- var execution = vm.UninstallSelectedCommand.ExecuteAsync(null);
+ execution = vm.UninstallSelectedCommand.ExecuteAsync(null);
await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
vm.CancelCommand.Execute(null);
- await execution;
- Assert.Equal(50, vm.Progress);
- Assert.Contains("cancelled after 1/2 completed", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
- Assert.Single(vm.FilteredApps);
- Assert.Equal("Cancelled", vm.FilteredApps[0].Status);
+ Assert.False(runningToken.IsCancellationRequested);
+ Assert.Equal("Stopping after app001. Its uninstaller finishes first, so it is not left half-removed.",
+ vm.StatusMessage);
+
+ pending.SetResult(0);
+ await execution.WaitAsync(TimeSpan.FromSeconds(10));
+
+ Assert.Contains("cancelled after 2/3 completed", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
+ Assert.Equal("app002", Assert.Single(vm.FilteredApps).Name);
await runner.Received(2).RunProcessAsync(
"winget",
Arg.Any(),
@@ -433,6 +440,57 @@ await runner.Received(2).RunProcessAsync(
}
finally
{
+ // Ended either way: a batch left running holds the install lock every later uninstall in this class needs.
+ pending.TrySetResult(0);
+ await execution.WaitAsync(TimeSpan.FromSeconds(10));
+ DialogService.Instance = previousDialog;
+ }
+ }
+
+ [Fact]
+ public async Task Dispose_WhileAnUninstallerRuns_StillEndsIt()
+ {
+ // Closing SysManager asks first while the uninstall holds its lock, and then ends what is running, as before.
+ var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ CancellationToken runningToken = default;
+ var runner = Substitute.For();
+ runner.RunProcessAsync(
+ "winget",
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any())
+ .Returns(callInfo =>
+ {
+ runningToken = callInfo.ArgAt(2);
+ runningToken.Register(() => pending.TrySetCanceled(runningToken));
+ started.TrySetResult(true);
+ return pending.Task;
+ });
+ var vm = NewVm(runner);
+ Seed(vm, 1);
+ vm.FilteredApps[0].Source = "winget";
+ vm.FilteredApps[0].IsSelected = true;
+
+ var previousDialog = DialogService.Instance;
+ var dialog = Substitute.For();
+ dialog.Confirm(Arg.Any(), Arg.Any()).Returns(true);
+ DialogService.Instance = dialog;
+ var execution = Task.CompletedTask;
+ try
+ {
+ execution = vm.UninstallSelectedCommand.ExecuteAsync(null);
+ await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ vm.Dispose();
+
+ Assert.True(runningToken.IsCancellationRequested);
+ }
+ finally
+ {
+ // Ended either way: a batch left running holds the install lock every later uninstall in this class needs.
+ pending.TrySetResult(0);
+ await execution.WaitAsync(TimeSpan.FromSeconds(10));
DialogService.Instance = previousDialog;
}
}
diff --git a/SysManager/SysManager/SysManager.csproj b/SysManager/SysManager/SysManager.csproj
index 4d95aa25..3f1b867b 100644
--- a/SysManager/SysManager/SysManager.csproj
+++ b/SysManager/SysManager/SysManager.csproj
@@ -13,9 +13,9 @@
$(NoWarn);NU1603;NU1701
false
- 1.130.51
- 1.130.51.0
- 1.130.51.0
+ 1.130.52
+ 1.130.52.0
+ 1.130.52.0
SysManager
SysManager — Windows system monitoring toolkit by laurentiu021. Network, updates, health, logs, safe deep cleanup.
https://github.com/laurentiu021/SystemManager
diff --git a/SysManager/SysManager/ViewModels/SystemFixesViewModel.cs b/SysManager/SysManager/ViewModels/SystemFixesViewModel.cs
index 8465305f..7342eed7 100644
--- a/SysManager/SysManager/ViewModels/SystemFixesViewModel.cs
+++ b/SysManager/SysManager/ViewModels/SystemFixesViewModel.cs
@@ -35,9 +35,9 @@ namespace SysManager.ViewModels;
///
public sealed partial class SystemFixesViewModel : ViewModelBase
{
- ///
- protected internal override IRelayCommand? EscapeCancel =>
- IsBusy ? CancelCommand : null;
+ // Escape stops nothing here, so there is no EscapeCancel: every run on this tab repairs or changes Windows, and
+ // Escape is a bare keypress. It stopped SFC, DISM and the scripted repairs half-way (#2834). The Cancel button
+ // still stops one, for a stop that is meant.
private readonly SystemFixService _service;
diff --git a/SysManager/SysManager/ViewModels/UninstallerViewModel.cs b/SysManager/SysManager/ViewModels/UninstallerViewModel.cs
index d73c0d70..1df106e3 100644
--- a/SysManager/SysManager/ViewModels/UninstallerViewModel.cs
+++ b/SysManager/SysManager/ViewModels/UninstallerViewModel.cs
@@ -29,6 +29,12 @@ public sealed partial class UninstallerViewModel : ViewModelBase
private readonly EtaCalculator _uninstallEta = new();
private CancellationTokenSource? _cts;
+ // The uninstaller running now has a token of its own, which only closing SysManager cancels. Cancel and Escape
+ // stop a batch before its next app instead: ending an uninstaller half-way can leave its program half-removed, and
+ // Escape did that from a bare keypress (#2834).
+ private CancellationTokenSource? _uninstallerCts;
+ private InstalledApp? _uninstalling;
+
public BulkObservableCollection AllApps { get; } = new();
public BulkObservableCollection FilteredApps { get; } = new();
@@ -209,6 +215,8 @@ private async Task UninstallSelectedAsync()
IsBusy = true;
_cts?.Dispose();
_cts = new CancellationTokenSource();
+ _uninstallerCts?.Dispose();
+ _uninstallerCts = new CancellationTokenSource();
int completed = 0;
int removed = 0;
int failed = 0;
@@ -238,13 +246,14 @@ private async Task UninstallSelectedAsync()
UninstallEtaText = _uninstallEta.Update(Progress);
var currentCompleted = false;
+ _uninstalling = app;
try
{
var local = string.IsNullOrWhiteSpace(app.Source)
&& !string.IsNullOrWhiteSpace(app.UninstallString);
var code = local
- ? await _service.UninstallLocalAsync(app, _cts.Token)
- : await _service.UninstallAsync(app.Id, _cts.Token);
+ ? await _service.UninstallLocalAsync(app, _uninstallerCts.Token)
+ : await _service.UninstallAsync(app.Id, _uninstallerCts.Token);
currentCompleted = true;
if (IsSuccessfulUninstallExitCode(code) && _service.IsStillRegistered(app))
@@ -284,7 +293,7 @@ private async Task UninstallSelectedAsync()
{
app.Status = $"Error: {ex.Message}";
failed++;
- currentCompleted = !_cts.IsCancellationRequested;
+ currentCompleted = !_uninstallerCts.IsCancellationRequested;
}
// A failed uninstaller launch (missing/blocked exe) must not abort the
// whole batch - record it on the row and continue with the next app.
@@ -308,6 +317,10 @@ private async Task UninstallSelectedAsync()
failed++;
currentCompleted = true;
}
+ finally
+ {
+ _uninstalling = null;
+ }
if (currentCompleted)
completed++;
@@ -630,8 +643,15 @@ internal static string DescribeRemoval(IReadOnlyList ticked)
return $"{ConfirmRemovalQuestion(ticked)}\n\n{lines}\n\n{string.Join(" ", new[] { folders, keys }.Where(s => s.Length > 0))}";
}
+ /// Stops a scan, or a batch before its next app: the uninstaller running now finishes.
[RelayCommand]
- private void Cancel() => _cts?.Cancel();
+ private void Cancel()
+ {
+ _cts?.Cancel();
+ if (_uninstalling is { } app)
+ StatusMessage = $"Stopping after {app.Name}. Its uninstaller finishes first, so it is not left "
+ + "half-removed.";
+ }
protected override void Dispose(bool disposing)
{
@@ -640,6 +660,8 @@ protected override void Dispose(bool disposing)
PropertyChanged -= OnVmPropertyChanged;
_cts?.Cancel();
_cts?.Dispose();
+ _uninstallerCts?.Cancel();
+ _uninstallerCts?.Dispose();
}
base.Dispose(disposing);
}