Skip to content

Process crashes with NullReferenceException in RunspacePoolInternal.DestroyRunspace when an open RunspacePool's cleanup timer fires during exit #28182

Open

Description

Prerequisites

Steps to reproduce

If a process exits while one of its local RunspacePools is still open, and that pool's idle-cleanup timer fires during shutdown, the process crashes with an unhandled NullReferenceException.

Save this as pool-exit.ps1. It shortens CleanupInterval from the 15-minute default to 1 second so the timer fires close to exit:

param([int]$ExitAfterMs = 920)
$pool = [runspacefactory]::CreateRunspacePool(1, 10)
$pool.CleanupInterval = [timespan]::FromSeconds(1)   # default is 15 minutes
$pool.Open()
$h = foreach ($i in 1..8) {
    $ps = [powershell]::Create(); $ps.RunspacePool = $pool
    [void]$ps.AddScript('Start-Sleep -Milliseconds 50')
    [pscustomobject]@{ PS = $ps; H = $ps.BeginInvoke() }
}
foreach ($x in $h) { [void]$x.PS.EndInvoke($x.H); $x.PS.Dispose() }
Start-Sleep -Milliseconds $ExitAfterMs   # exit just as the cleanup timer fires
# The pool is intentionally not disposed. A long-lived module-level pool behaves the same way.

Run it in a loop. It is timing dependent, so vary the exit delay a little:

for i in $(seq 1 40); do pwsh -NoProfile -File pool-exit.ps1 -ExitAfterMs $((880 + (i % 8) * 20)) || echo "crash on run $i"; done

In my tests:

  • 9 of 40 runs crashed with this script.
  • 6 of 30 runs crashed with a variant that runs Get-Command in the pool.
  • When the timer fires well before exit (exit at 3.5 s), 0 of 10 runs crashed.
  • When $pool.Dispose() is called before exit, 0 of 30 runs crashed.

Expected behavior

pwsh exits cleanly, with exit code 0.

Actual behavior

pwsh crashes during shutdown with an unhandled exception on a thread-pool timer thread. The exit code is 134 on macOS and -532462766 (0xE0434352) on Windows.

How we found it: maester365/maester CI on GitHub Actions (windows-2025). Our Pester suite ran PSScriptAnalyzer's tests about 15 minutes before the end of the suite. PSScriptAnalyzer's CommandInfoCache creates RunspaceFactory.CreateRunspacePool(1, 10) and never disposes it. All tests passed, then pwsh crashed on exit 15 minutes and 1 second after the PSScriptAnalyzer tests started. That matches the default CleanupInterval. It is intermittent because it only happens when the exit falls inside that window. Any module that keeps a pool open for the life of the process can trigger it.

Error details

Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
   at System.Management.Automation.Runspaces.Internal.RunspacePoolInternal.DestroyRunspace(Runspace runspace)
   at System.Management.Automation.Runspaces.Internal.RunspacePoolInternal.CleanupCallback(Object state)
   at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
--- End of stack trace from previous location ---
   at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
   at System.Threading.TimerQueue.FireNextTimers()
   at System.Threading.ThreadPoolWorkQueue.Dispatch()
   at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart()
   at System.Threading.Thread.StartCallback()

Cause, from reading the source:

  1. When the primary runspace closes, LocalRunspace.DoCloseHelper (with closeAllOpenRunspaces) disposes every Opened runspace in Runspace.RunspaceList. That includes idle runspaces still in a RunspacePool's internal pool stack. The pool is not told: it stays Opened and _cleanupTimer stays armed.
  2. CleanupCallback only returns early when the pool is Closing. Otherwise, while totalRunspaces > minPoolSz, it pops an idle runspace and calls DestroyRunspace.
  3. DestroyRunspace runs runspace.Events.ForwardEvent -= OnRunspaceForwardEvent. LocalRunspace.Events returns null once the runspace is closed (GetExecutionContext is null), so this throws. The exception escapes a Timer callback and terminates the process.

Possible fixes (any one of these would help):

  • In DestroyRunspace, handle runspaces that are already closed: use runspace.Events?.ForwardEvent, and skip Close()/Dispose() if the runspace is already closed.
  • In CleanupCallback, skip popped runspaces whose RunspaceStateInfo.State is not Opened, and catch exceptions so a timer callback can never bring down the process.
  • When disposing all open runspaces at shutdown, also stop the cleanup timers of the pools that own them.

Environment data

Name                           Value
----                           -----
PSVersion                      7.6.2
PSEdition                      Core
GitCommitId                    7.6.2
OS                             macOS 27.0.1
Platform                       Unix
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0…}
PSRemotingProtocolVersion      2.4
SerializationVersion           1.1.0.1
WSManStackVersion              3.0

Also seen on GitHub Actions windows-2025 (image 20260925.250.1) with the preinstalled pwsh 7.x.

Visuals

No response

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions