Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,15 +418,23 @@ Key services:
A runner REUSES its runspace across calls, because starting that child
and completing a remoting handshake with it is the slow part and the services
that use it call in bursts (`DnsService` six times, `EdgeOneDriveService` four).
Three properties make reuse safe: the runspace state is re-checked on every
lease, so a child killed from outside is discarded and rebuilt rather than
failing every later call; pipelines are serialised behind a gate, because a
runspace runs one at a time and a shared one turns concurrent calls into a
conflict; and the runspace is evicted after ~20 s idle, which matters because
eleven runners are constructed directly in `MainWindowViewModel`'s designer graph
and nothing ever disposes them — without eviction a dozen `powershell.exe`
processes would be resident for the whole run. `Dispose` releases it
deterministically where a consumer is disposed.
Three properties make reuse safe: the runspace state, and whether its child
process is still running, are re-checked on every lease, so a child killed from
outside is discarded and rebuilt rather than failing every later call (the state
alone goes on reading open for about a minute after the child dies, and tearing
that runspace down takes the same minute, so it is left to the thread pool, #2608);
pipelines are serialised behind a gate, because a runspace runs one at a time
and a shared one turns concurrent calls into a conflict; and the runspace is
evicted after ~20 s idle, which matters because eleven runners are constructed
directly in `MainWindowViewModel`'s designer graph and nothing ever disposes
them — without eviction a dozen `powershell.exe` processes would be resident for
the whole run. `Dispose` releases it deterministically where a consumer is disposed.
A session that stops working under a run surfaces as `RuntimeException`, the type
every service already turns into its failed state, rather than as the two
`SystemException`s PowerShell raises for it. Cancelling hands `Stop()` to the
thread pool: `Stop()` waits for the stop to land, and on a session whose child had
died that froze the caller of `Cancel()`, a Cancel button's UI thread, for about a
minute (#2608).
- `WingetService` — shells out to `winget` and parses its table output.
- `WindowsUpdateService` — drives Windows Update through the WUA COM API
(scan, select, install) with progress reporting, behind `IWindowsUpdateService`; backs
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,29 @@ 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.127.3] - 2026-10-09

**Cancelling a tab's PowerShell work no longer freezes SysManager when the PowerShell behind it has died, and a
PowerShell that stopped working is reported by the tab that was using it** (#2608).

### Fixed

- **Pressing Cancel while a script runs returns at once.** Cancelling waited for PowerShell to confirm the stop, on the
thread that draws the window. When the `powershell.exe` running the script had ended, closed from Task Manager for
example, that confirmation took about a minute to fail, and SysManager stopped responding for all of it. The stop
is now asked for in the background, and nothing it throws can come out of Cancel. Cancel has waited for the stop
since the first release; it could take that long whenever the script ran in a separate `powershell.exe`, which
every session has done since 1.114.4.
- **A PowerShell session that stopped working is reported by the tab, not by the error window.** PowerShell raises
two kinds of error for a session that can no longer run anything, and neither was one the tabs' own handling
catches, so on Preinstalled Apps, Defender Tweaks, Edge/OneDrive Remover, Scheduled Maintenance, Restore Points,
System Fixes and Task Scheduler it reached the app-wide error handler. Both now arrive as the failure each of those
tabs already reports for a script that failed.
- **The next script after a `powershell.exe` that ended starts a fresh one straight away.** SysManager keeps one
`powershell.exe` for a short burst of scripts. If it had ended in between, the next script still went to it, waited
about a minute and failed. It is now replaced before the script starts, without waiting for the old one to be
cleaned up. It had been reused that way since SysManager first kept it, in 1.78.7.

## [1.127.2] - 2026-10-09

**The note SysManager leaves when it crashes no longer keeps your Windows user name.** The error message in it is now
Expand Down
40 changes: 40 additions & 0 deletions SysManager/SysManager.IntegrationTests/PowerShellRunnerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,46 @@ public async Task Runner_RebuildsARunspaceThatIsNoLongerOpen()
Assert.Equal(2, built);
}

/// <summary>
/// A cached runspace whose child process has exited is rebuilt rather than run on.
/// </summary>
/// <remarks>
/// The out-of-process runspace goes on reading <c>Opened</c> for about a minute after its child dies, so the
/// state check above does not see it, and a run started on it in that minute waited the whole minute and then
/// failed with "The background process closed or ended abnormally" (#2608). A process that has really exited
/// stands in for the killed child; the runspace itself is in process and stays open, so only the lease's check
/// of the child can tell the difference.
/// </remarks>
[Fact]
public async Task Runner_RebuildsARunspaceWhoseChildHasExited()
{
using var child = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(
"cmd.exe", "/c exit 0")
{
UseShellExecute = false,
CreateNoWindow = true
})!;
using (var bounded = new CancellationTokenSource(TimeSpan.FromSeconds(30)))
await child.WaitForExitAsync(bounded.Token);

var built = 0;
using var runner = new PowerShellRunner(
action => Task.Run(action),
createRunspace: () =>
{
built++;
return (System.Management.Automation.Runspaces.RunspaceFactory.CreateRunspace(
System.Management.Automation.Runspaces.InitialSessionState.CreateDefault2()),
null, built == 1 ? child : null);
});

await runner.RunAsync("2 + 2");
Assert.Equal(1, built);

Assert.Equal(4, (int)(await runner.RunAsync("2 + 2"))[0].BaseObject);
Assert.Equal(2, built);
}

/// <summary>
/// Two calls that overlap on an ALREADY-CACHED runspace both complete.
/// </summary>
Expand Down
33 changes: 33 additions & 0 deletions SysManager/SysManager.Tests/PipelineStoppedDetectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,39 @@ public void AnUnrelatedException_IsNotTakenForOurStop()
Assert.False(PowerShellRunner.IsRemotingTornDownByOurStop(new PipelineStoppedException()));
}

// ── A session that stopped working under a run (#2608) ──
//
// InvalidRunspaceStateException and InvalidPowerShellStateException derive from SystemException, so neither a
// service's catch (RuntimeException) nor a tab's catch (InvalidOperationException) caught them, and they reached
// the app-wide handler. The runner turns both into a RuntimeException; these pin which exceptions it treats so.

[Fact]
public void ARunspaceThatIsNotOpen_IsABrokenSession()
=> Assert.True(PowerShellRunner.IsSessionBroken(
new System.Management.Automation.Runspaces.InvalidRunspaceStateException("not open")));

[Fact]
public void APipelineInAStateItCannotRunFrom_IsABrokenSession()
=> Assert.True(PowerShellRunner.IsSessionBroken(new InvalidPowerShellStateException("failed")));

[Fact]
public void ABrokenSession_IsRecognisedWhenWrapped()
=> Assert.True(PowerShellRunner.IsSessionBroken(
new InvalidOperationException("outer",
new System.Management.Automation.Runspaces.InvalidRunspaceStateException("not open"))));

[Fact]
public void WhatTheServicesAlreadyHandle_IsNotABrokenSession()
{
// These already reach a handler as themselves. Turning them into a new RuntimeException would bury what they
// said, and would turn a stop into a failure.
Assert.False(PowerShellRunner.IsSessionBroken(new RuntimeException("script error")));
Assert.False(PowerShellRunner.IsSessionBroken(new PipelineStoppedException()));
Assert.False(PowerShellRunner.IsSessionBroken(
new System.Management.Automation.Remoting.PSRemotingTransportException("closed")));
Assert.False(PowerShellRunner.IsSessionBroken(new InvalidOperationException("already started")));
}

/// <summary>
/// Builds a <see cref="RemoteException"/> carrying <paramref name="serialized"/>.
/// </summary>
Expand Down
159 changes: 159 additions & 0 deletions SysManager/SysManager.Tests/PowerShellRunnerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,150 @@ public async Task RunAsync_BuildsAnOutOfProcessRunspace_WhateverTheElevation(boo
+ "every script that uses a Utility, Management, Appx or Defender cmdlet fails and returns nothing");
}

// ── A session that stops working (#2608) ───────────────────────────────

/// <summary>
/// A runspace that is not open under a run surfaces as the <see cref="RuntimeException"/> every service handles.
/// </summary>
/// <remarks>
/// <c>BeginInvoke</c> throws <see cref="InvalidRunspaceStateException"/> for a runspace that is not open, which is
/// what a session whose child process has died turns into. That type derives from <see cref="SystemException"/>,
/// so it went past the services' <c>catch (RuntimeException)</c> and the tabs' <c>catch
/// (InvalidOperationException)</c> alike, to the app-wide handler. An open that does nothing reaches the same
/// state without killing anything.
/// </remarks>
[Fact]
public async Task RunAsync_OnARunspaceThatIsNotOpen_ThrowsTheRuntimeExceptionServicesHandle()
{
var runspace = RunspaceFactory.CreateRunspace(InitialSessionState.Create());
using var runner = new PowerShellRunner(
action => Task.Run(action),
openRunspace: _ => Task.CompletedTask,
createRunspace: () => (runspace, null, null));

var ex = await Assert.ThrowsAsync<RuntimeException>(() => runner.RunAsync("'never-runs'"));

Assert.IsType<InvalidRunspaceStateException>(ex.InnerException);
}

/// <summary>
/// Cancelling only asks for the stop: <c>Cancel()</c> returns before the pipeline has stopped.
/// </summary>
/// <remarks>
/// The registration used to call <c>PowerShell.Stop()</c> itself, on the thread that called <c>Cancel()</c>, and
/// <c>Stop()</c> waits for the stop to land. On a session whose child process had died that took until the
/// transport gave up: 58 seconds, with the app frozen for all of it when Cancel was pressed. The stop is handed to
/// the scheduler instead, which here holds it until the test runs it, so the order is decided by the test and
/// not by timing: the loop's own deadline is 20 seconds away, so a run that had finished would mean the stop ran
/// inside <c>Cancel()</c>. The deadline is there so a stop that never lands cannot hang the suite.
/// </remarks>
[Fact]
public async Task Cancel_AsksForTheStop_WithoutWaitingForThePipelineToStop()
{
var stops = new List<Action>();
using var runner = new PowerShellRunner(
action => Task.Run(action),
openRunspace: runspace => Task.Run(runspace.Open),
createRunspace: () => (RunspaceFactory.CreateRunspace(InitialSessionState.CreateDefault2()), null, null),
scheduleStop: stop =>
{
lock (stops) stops.Add(stop);
return Task.CompletedTask;
});
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
runner.LineReceived += line =>
{
if (line.Text == "started") started.TrySetResult();
};
using var cts = new CancellationTokenSource();

var run = runner.RunAsync(
"'started'; $deadline = [DateTime]::UtcNow.AddSeconds(20); "
+ "while ([DateTime]::UtcNow -lt $deadline) { [System.Threading.Thread]::Sleep(10) }",
cancellationToken: cts.Token);
await started.Task.WaitAsync(TimeSpan.FromSeconds(30));

cts.Cancel();

Action stop;
lock (stops) stop = Assert.Single(stops);
Assert.False(run.IsCompleted, "the pipeline stopped inside Cancel(), so a stop that hangs freezes the caller");

stop();
await Assert.ThrowsAsync<OperationCanceledException>(() => run);
}

/// <summary>
/// A cached runspace whose child process is gone is rebuilt rather than run on.
/// </summary>
/// <remarks>
/// The runspace goes on reading Opened for about a minute after its child dies, and a run started on it in that
/// minute waited the whole minute and then failed (#2608). A <see cref="Process"/> object with no process behind
/// it is the case this suite can build without starting one: <c>HasExited</c> throws for it, and that counts as
/// gone. A child that has really exited is in the integration suite.
/// </remarks>
[Fact]
public async Task ACachedRunspaceWhoseChildProcessIsGone_IsRebuilt()
{
var built = 0;
using var runner = new PowerShellRunner(
action => Task.Run(action),
openRunspace: runspace => Task.Run(runspace.Open),
createRunspace: () =>
{
built++;
return (RunspaceFactory.CreateRunspace(InitialSessionState.CreateDefault2()), null, new Process());
});

await runner.RunAsync("2 + 2");
Assert.Equal(4, (int)(await runner.RunAsync("2 + 2"))[0].BaseObject);

Assert.Equal(2, built);
}

/// <summary>
/// The next run does not wait for the session it replaces to be torn down.
/// </summary>
/// <remarks>
/// Disposing a runspace whose child has died takes about a minute, and the lease used to do it before building the
/// replacement: with the check above alone, the next run measured 60 seconds against a killed child and then
/// succeeded. Here the old session's teardown is held until the end of the test, so the second run can only
/// finish if it did not wait for it. The bounded waits are there for a failing run, not for the assertions.
/// </remarks>
[Fact]
public async Task ReplacingASessionWhoseChildIsGone_DoesNotWaitForItsTeardown()
{
var teardownMayFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var teardownStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var built = 0;
using var runner = new PowerShellRunner(
action => Task.Run(action),
openRunspace: runspace => Task.Run(runspace.Open),
createRunspace: () =>
{
built++;
return built == 1
? (RunspaceFactory.CreateRunspace(InitialSessionState.CreateDefault2()),
new HeldDisposable(teardownStarted, teardownMayFinish.Task), new Process())
: (RunspaceFactory.CreateRunspace(InitialSessionState.CreateDefault2()), null, null);
});

try
{
await runner.RunAsync("2 + 2");
// From another thread: the lease runs inside the call until its first real wait, so a teardown that held it
// up would hold up this line too, and the bounded wait below would never be reached.
var second = Task.Run(() => runner.RunAsync("3 + 3"));
await teardownStarted.Task.WaitAsync(TimeSpan.FromSeconds(30));

Assert.Equal(6, (int)(await second.WaitAsync(TimeSpan.FromSeconds(30)))[0].BaseObject);
}
finally
{
teardownMayFinish.TrySetResult();
}
}

private static PowerShellRunner CreateRunnerWithOpenFailure(Exception exception, bool isElevated)
{
var runspace = RunspaceFactory.CreateRunspace(InitialSessionState.Create());
Expand All @@ -494,6 +638,21 @@ public void Dispose()
}
}

/// <summary>A teardown that says it started and then waits to be let go.</summary>
/// <remarks>
/// For at most two minutes, which is longer than a test using it waits for anything. With an equal bound, a
/// teardown that held up the run gave up first, the run then finished inside the test's own wait, and the test
/// passed against the very defect it is there to catch.
/// </remarks>
private sealed class HeldDisposable(TaskCompletionSource started, Task mayFinish) : IDisposable
{
public void Dispose()
{
started.TrySetResult();
mayFinish.Wait(TimeSpan.FromMinutes(2));
}
}

[Theory]
[InlineData("powershell")]
[InlineData("powershell.exe")]
Expand Down
Loading