Skip to content

fix: cancelling PowerShell work no longer freezes the app when its powershell.exe has died - #2638

Merged
laurentiu021 merged 1 commit into
mainfrom
fix/powershell-broken-session
Oct 9, 2026
Merged

laurentiu021 merged 1 commit into
mainfrom
fix/powershell-broken-session

Conversation

@laurentiu021

Copy link
Copy Markdown
Owner

Closes #2608.

Every script SysManager runs goes through PowerShellRunner into a Windows PowerShell 5.1 child. I reproduced what
happens when that child dies, with a console program that runs a script printing its own $PID, ends exactly that
powershell.exe, and times what follows:

Case Before After
Cancel while the script runs, child alive Cancel() 7–17 ms 0 ms
Cancel after the child running the script died Cancel() blocked 58 s 0 ms
Next script after the kept child died while idle waited 60 s, then PSRemotingTransportException 245–252 ms, succeeds

Killing the child mid-run produced PSRemotingTransportException every time, which the services already handle. The
two types the issue names are what BeginInvoke throws for a runspace that is no longer open: an in-process probe
gives InvalidRunspaceStateException for one that never opened and for one that was closed. Both derive from
SystemException, so they pass the services' catch (RuntimeException) and the tabs' catch (InvalidOperationException); RestorePointService.ListAsync and the Restore Points and Task Scheduler view-models
catch nothing else around that call.

What changes, all in PowerShellRunner:

  • Cancelling never waits and never throws. The registration used to call ps.Stop() on the thread that called
    Cancel(), the UI thread for a Cancel button, and Stop() waits for the stop to land; on a dead child that was
    until the transport gave up. RequestStop hands the stop to the thread pool, through a new scheduleStop seam,
    and logs what Stop() throws there. The re-assert after BeginInvoke goes through it too.
  • A broken session is a RuntimeException. IsSessionBroken matches the two types, directly or one level in, and
    the run turns them into RuntimeException("The Windows PowerShell session stopped working while the script was running."), as it already does for a host that cannot start or open in time. BeginInvoke moved inside the
    existing try, so one handler covers it and the wait.
  • A kept runspace whose child has exited is replaced. The runspace goes on reading Opened for about a minute
    after its child dies, so the lease now also asks RunspaceResources.ChildHasExited. Disposing that runspace takes
    the same minute, which the first version of this change still spent in the lease before building the new one, so
    Retire disposes a dead child's resources on the thread pool and a live one's at once, in the lease, the idle
    eviction and Dispose.

Not changed: when the child dies in the middle of a script, the run itself still ends only when the transport gives
up, about a minute later. Cancel no longer freezes anything in that minute, and the tab then reports the failure. I
have opened #2637 for ending that run as soon as the child exits.

Tests:

  • PowerShellRunnerTests: a runspace that is never opened gives RuntimeException with InvalidRunspaceStateException
    inside; Cancel() hands one stop to a scheduler that holds it, and the endless script is still running when
    Cancel() returns, then stops with OperationCanceledException once the stop is run; a cached runspace whose
    process handle refers to nothing is rebuilt; and a replacement does not wait for the old session's teardown, which
    the test holds until it ends.
  • PipelineStoppedDetectionTests: which exceptions IsSessionBroken takes, including that a RuntimeException, a
    stop, a transport failure and an InvalidOperationException are left as they are.
  • IntegrationTests/PowerShellRunnerTests.Runner_RebuildsARunspaceWhoseChildHasExited: the same rebuild with a real
    process that has exited.
  • Each unit test was seen failing first: an inline Stop() leaves the scheduler empty; without the translation
    InvalidRunspaceStateException arrives as itself; a predicate that matches nothing, misses the inner exception or
    matches every SystemException fails its shape test; a lease that ignores the child, or reads a dead handle as
    alive, builds once instead of twice; and a teardown done in the lease holds the next run past its 30-second bound.
    That last test passed against the defect at first, twice, for two separate reasons: the hold and the test's wait had
    the same 30-second limit, and then the lease ran inside the RunAsync call itself. It now holds for two minutes and
    starts the run from another thread.
  • The cancel test's script ends itself after 20 seconds, as ArchitectureTests.NoTestScript_CanRunForever requires,
    so a stop that never lands fails the test instead of hanging the suite.

Docs: ARCHITECTURE.md (the PowerShellRunner entry), CHANGELOG 1.127.3. Version 1.127.3.

…wershell.exe has died

PowerShellRunner's cancellation called ps.Stop() on the thread that called Cancel(), and Stop()
waits for the stop to land: with the child process dead, Cancel() blocked 58 s. The stop now goes to
the thread pool and cannot throw. InvalidRunspaceStateException and InvalidPowerShellStateException,
which no service or tab caught, now arrive as RuntimeException. A kept runspace whose child has
exited is replaced before the next run, which had waited 60 s and failed, and its teardown is left
to the thread pool.

Closes #2608.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Cross-app — a PowerShell session that breaks mid-run escapes the tabs' error handling, and cancelling it can throw

1 participant