Skip to content

Serialize CommandInfo lookups onto a single dedicated runspace - #2

Merged
jessehouwing merged 3 commits into
mainfrom
copilot/marshal-concurrent-calls-to-sequential
Aug 19, 2026
Merged

Serialize CommandInfo lookups onto a single dedicated runspace#2
jessehouwing merged 3 commits into
mainfrom
copilot/marshal-concurrent-calls-to-sequential

Conversation

Copilot AI commented Aug 19, 2026

Copy link
Copy Markdown

Follow-up to PowerShell#2206 / issue PowerShell#2205. CommandInfoCache resolved commands through a RunspacePool(1, 10), allowing up to ten Get-Command invocations to run concurrently across separate runspaces. The PowerShell engine's command discovery state is not thread safe (PowerShell#4003), which surfaced as intermittent CommandNotFoundException for Get-Command itself and non-deterministic diagnostic counts.

A runspace pool doesn't help here: it already guarantees a given runspace is used by only one PowerShell instance at a time, and that per-runspace serialization is precisely the configuration that fails. The unsafe state is engine-global, so the gate has to be global too — at which point additional pooled runspaces are never concurrently active.

Changes

  • Engine/CommandInfoCache.cs

    • Replaced the runspace pool with a single long-lived Runspace.
    • Added a re-entrant instance monitor around the engine invocation, including the retry loop. A monitor rather than a SemaphoreSlim so a future re-entrant lookup can't self-deadlock.
    • Cache hits remain lock-free — only misses reach the engine and serialize.
    • Dispose acquires the same lock; a lookup that acquires it after disposal returns null rather than touching a disposed runspace.
    • Retained the retry / CommandNotFoundException fallback from Fix intermittent "The term 'Get-Command' is not recognized" failures during recursive analysis PowerShell/PSScriptAnalyzer#2206 as a safety net for hosts that drive the engine from other threads.
  • Tests/Engine/CommandInfoCacheConcurrency.tests.ps1 (new)

    • Resolves 40 commands from thread pool threads via a small C# driver and asserts each lookup resolves. The driver is C# because invoking a script block on a thread pool thread introduces runspace affinity problems of its own.
    • Runs the analyzer once first: touching Helper.Instance before the cmdlet does installs a helper with no command invocation context, which breaks every later analysis in the process. Pre-existing engine fragility, called out here so the test doesn't trip it.
// Only cache misses reach this; hits are served from the ConcurrentDictionary lock-free.
lock (_runspaceLock)
{
    if (disposed) { return null; }

    using (var ps = System.Management.Automation.PowerShell.Create())
    {
        ps.Runspace = _runspace;
        // ...
    }
}

Performance

Invoke-ScriptAnalyzer -Path Tests -Recurse, warm, alternating runs:

before after
steady state 15.0s / 14.5s 15.1 – 15.8s
outliers 81s, 106s, 116s none
diagnostics 4187 – 4199 4199

Steady-state cost is within noise. The pre-change build also produced large outliers and varying diagnostic counts, so serializing appears to trade little or no throughput for determinism.

Copilot AI and others added 3 commits August 19, 2026 15:33
Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com>
Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com>
Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com>
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.

2 participants