Skip to content

lefthook-powershell: PSScriptAnalyzer NullReferenceException is an in-process rule race, not cross-target state #598

Closed

Description

@kyle-sexton

This was generated by AI during triage.

Problem

components/lefthook-powershell/psscriptanalyzer-staged.ps1 says PSScriptAnalyzer 1.25.0 "can leak engine state between sequential targets in one process", and the per-target pwsh worker is the fix. Measurements in melodic-software/dotfiles#774 show the failure also happens inside one fresh process analyzing one file:

  • ChezmoiRender.psm1 analyzed alone, one fresh pwsh -NoProfile worker per run: 15 of 60 runs failed with no other load, and 79 of 200 with 6 workers running at once.
  • Every captured stack goes Helper.GetExportedFunction → CommandInfo.ResolveParameter inside ProvideCommentHelp or AvoidReservedCharInCmdlet. The error is a NullReferenceException, or in 2 cases an InvalidOperationException ("more than one dynamic module in each dynamic assembly").
  • That path runs only when a file contains Export-ModuleMember with a parameter. Once the call was removed, the same file failed 0 of 200 runs.

Upstream maintainers say the cause is a CommandInfo cache that is not thread-safe while rules run in parallel: PowerShell/PSScriptAnalyzer#1867 (open), #2188 (closed as a duplicate), and #1708 (open; same race in UseCorrectCasing, reproduced on 1.25.0). 1.25.0 is the latest release, and upstream main still calls ResolveParameter without a guard.

Proposal

  1. Correct the runner header: process isolation does not stop this failure.
  2. In psscriptanalyzer-target.ps1, retry a target once when every error is RULE_ERROR with a NullReferenceException or InvalidOperationException. Findings and other errors still fail on the first run. At the measured 40% worst-case rate per run, a single retry leaves about 16% (judgment, assuming independent runs), so the README should also tell consumers that a .psm1 with a single-function Export-ModuleMember can drop the call. With no Export-ModuleMember, a script module exports all its functions and aliases (Export-ModuleMember docs).

Tradeoff: that retry would break the worker's documented no-retry, fail-closed contract, so the decision belongs to a maintainer.

Activity

  1. kyle-sexton commented on Sep 23, 2026

    @kyle-sexton
    ContributorAuthor

    This was generated by AI during triage.

    Decision

    Keep both lanes fail-closed and add no retry. PSScriptAnalyzer 1.25.0 is still the latest release, and main has no fix. The engine has no switch to run rules sequentially, and pinning an older version doesn't help because the race was reported in 2021 (PowerShell/PSScriptAnalyzer#1708). The durable fix is upstream PowerShell/PSScriptAnalyzer#2206, which replaces the failing CommandInfo.ResolveParameter call in Helper.GetExportedFunction. It is open and has no release date. Until it ships, the approach is to remove the constructs that trigger the race.

    Triggers removed

    • melodic-software/claude-code-plugins: fix(machine-health): drop Export-ModuleMember from Mock-Helpers claude-code-plugins#4374 (merged). Removes Export-ModuleMember from plugins/machine-health/skills/audit/tests/helpers/Mock-Helpers.psm1. The exported set is unchanged: the same 14 functions are exported before and after, checked by importing the module. Invoke-ScriptAnalyzer on that file, one fresh pwsh per run, threw on main in 3 of 10 runs; the surfaced error was the NRE. It threw in 0 of 20 runs on the branch.
    • melodic-software/medley: melodic-software/medley#2038 (merged). Disables PSUseCorrectCasing (the #1708 path), with a comment that names the re-enable condition.

    Not changed: claude-code-proxy

    scripts/CcProxyCa.psm1 and scripts/CcProxyActivation.psm1 still call Export-ModuleMember, deliberately. Their export lists hide private helpers: CcProxyCa defines 30 functions and exports 24, and CcProxyActivation defines 31 and exports 17. Removing the call would therefore make 20 internal helpers public. It would also break tests/pester/StartProxy.Tests.ps1, whose stubbed CcProxyActivation.psm1 appends a second Export-ModuleMember -Function Get-ScheduledTask[, Get-ScheduledTaskInfo]. That works only because the two calls add their lists together. Without the base list, the generated module would export only the stubs.

    The copies of Export-ModuleMember in that test file are string literals. They are written into generated modules under the system temp directory at test time. PSScriptAnalyzer does not see them as commands: the test file's AST contains 0 CommandAst nodes named Export-ModuleMember. They need no change.

    Those two modules stay exposed to the race until #2206 ships. Measured locally with the repo's settings file, one fresh pwsh per run, CcProxyCa.psm1 threw the NRE in 1 of 10 runs. CcProxyActivation.psm1 was not measured. The proxy's settings file leaves PSUseCorrectCasing at its default, which is disabled. If they flake before then, there are two options:

    • A module manifest with FunctionsToExport, which changes how the modules are imported.
    • A per-rule sequential pass for those files.

    Sunset

    1. Adopt the first PSScriptAnalyzer release that contains Fix intermittent "The term 'Get-Command' is not recognized" failures during recursive analysis and improve performance while doing so. PowerShell/PSScriptAnalyzer#2206: bump the exact pin in ci-workflows Invoke-Pssa.ps1 and in the lefthook component.
    2. Then re-enable PSUseCorrectCasing in medley, and drop the "keep PSUseCorrectCasing disabled" guidance in the runner.

    Runner header correction (proposed, not edited here because the file is synced)

    The header of components/lefthook-powershell/psscriptanalyzer-staged.ps1 says:

    PSScriptAnalyzer 1.25.0 can leak engine state between sequential targets in one process and intermittently throw a NullReferenceException even though every target passes in isolation.

    The mechanism it describes is wrong. The failure reproduces in a single fresh process on a single file (dotfiles#774: 15/60, and 79/200 under load; the NRE above came from one file in a fresh process). The cause is the CommandInfo race between rules running in parallel inside one Invoke-ScriptAnalyzer call (PowerShell/PSScriptAnalyzer#1867, #2188). Proposed replacement:

    PSScriptAnalyzer 1.25.0 runs rules in parallel against a shared cached CommandInfo, which intermittently throws a NullReferenceException on files that call Export-ModuleMember or when PSUseCorrectCasing is enabled (PowerShell/PSScriptAnalyzer#1867, #1708; fixed upstream in #2206). A fresh worker per target keeps one failure from masking other targets; it does not prevent the race.

  2. jessehouwing commented on Sep 26, 2026

    @jessehouwing

    @kyle-sexton it would help if you could verify the proposed fix in PowerShell/PSScriptAnalyzer#2206 solves the issue for you as well and report back on the PR.

    Instructions here:
    https://jessehouwing.net/fixing-complex-bugs-with-ai-deepdive-into-powershellscriptanalyzer/#so-please-go-and-break-it

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