Search code examples
powershelldebuggingverbose

How can I write debug messages to the console and step through my code (debug), when using `ForEach-Object -AsJob -Parallel ...`?


I have optimized my code for performance by utilizing ForEach-Object -AsJob -Parallel ....

However, it comes with major drawbacks, as I can't write debug messages to the console, nor debug my code by stepping through it.

So, every time I want to debug my code, I have to remove -AsJob and -Parallel options, and then e.g. add the debug logging statements, which is something I want to avoid(!). I don't see how this scale - what if I had hundreds of parallel for each loops that all appeared like "black boxes".

How can I write debug messages to the console and step through my code, when using ForEach-Object -AsJob -Parallel ...?

$appPermissionsJob = $servicePrincipals | ForEach-Object -AsJob -ThrottleLimit $ThrottleLimit -Parallel {
  $spApplicationPermissions = $using:spApplicationPermissions
  $servicePrincipalId = $_.Id

  try {
    $applicationPermissions = Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $servicePrincipalId -All -PageSize 999

    if ($applicationPermissions -ne $null) {
      $applicationPermissions | ForEach-Object {
        Write-Host "Processing service principal ${servicePrincipalId} with app role $($_.ResourceId)"
        if ($_.ResourceId -eq $using:MSGraphServicePrincipalId) {
          $item = New-Object PSObject -Property ([ordered] @{
              ServicePrincipalId     = $_
              ApplicationPermissions = $applicationPermissions
            })
          $spApplicationPermissions.TryAdd($servicePrincipalId, $item)
        }
      }
    }
  }
  catch {
    Write-Verbose "Failed to download delegated permissions for service principal ${servicePrincipalId}: $($_.Exception.Message)"
    $dictFailed.TryAdd($servicePrincipalId, $_.Exception.Message)
  }
}

Solution

  • As for printing verbose / debugging messages:

    • Generally, cmdlets that accept script blocks expect those script blocks to control their stream output themselves, with the caller needing to use redirections in order to capture or suppress such output.

      • As such, unless output of a given stream, such as the verbose stream in the case at hand, is turned on inside the script block, it won't surface in the caller's context.

      • Therefore, in order to surface verbose-stream output, you must either use Write-Verbose -Verbose ... calls inside your script block (i.e. using the common -Verbose parameter), or set $VerbosePreference = 'Continue' at the start of the block.

    • However, due to a putative bug, as of PowerShell (Core) 7.4.x, output from thread-based parallelism - both via ForEach-Object -Parallel (with or without -AsJob) and Start-ThreadJob - is unexpectedly additionally filtered by the caller's stream-controlling settings (whether via common parameters or the equivalent preference variables).

    Here are minimal examples that provide a workaround:

    # !! The *outer* -Verbose arguably shouldn't be necessary in either case, 
    # !! but is, as of PowerShell 7.4.x
    
    ForEach-Object -Parallel { Write-Verbose hi -Verbose } -Verbose
    
    ForEach-Object -AsJob -Parallel { Write-Verbose hi -Verbose } |
      Receive-Job -Wait -AutoRemoveJob -Verbose
    

    As for breaking into the debugger inside a ForEach-Object -AsJob -Parallel script block:

    The short of it is that debugging such jobs, as well as the technologically closely related thread jobs created with Start-ThreadJob, via the Debug-Job cmdlet, is currently (as of PowerShell 7.4.x) very limited and cumbersome, due to numerous bugs.

    • A conceptual challenge is that copies of the code in the script block passed to -Parallel run in multiple runspaces (threads) by design, one for each pipeline input object, albeit capped by the so-called throttle limit, i.e. the max. number of runspaces that are allowed to run simultaneously at a given time; the default limit is 5, but can be modified via the -ThrottleLimit parameter.

    • Therefore, you can only debug one of these runspaces at a time.

      • While placing a Wait-Debugger statement at the start of the script block should in theory allow you to target each runspace sequentially, a bug currently prevents the use of Wait-Debugger:

    • The upshot is that you currently cannot make runspaces wait for a debugger to attach.

      • Even a workaround via an aux. function and a Set-PSBreakpoint -Command call from inside the script block is currently not an option, due to a separate bug:

      • See GitHub issue #39.

    • Debug-Job fails, unless the job has already entered the Running state after creation.

      • Not only do you therefore need to ensure that the job is in the desired state manually, the Running state is reported prematurely, requiring an artificial sleep interval to work around this bug:

    • Generally, it would be nice to be able to start a job in a manner that makes the parallel runspaces hold off on executing until a debugger connects, or to be able to set breakpoints from outside the job's script block.

      • Adding such a feature is the subject of GitHub issue #4345.
      • The linked issue discusses the feature in terms of adding -Breakpoints and -WaitDebugger parameters to the dedicated job cmdlets (Start-Job and Start-ThreadJob), which would have to be added to the ForEach-Object -AsJob -Parallel parameter set too.

    Here's a minimal example that shows what you can currently do (as of PowerShell 7.4.x):

    • Due to the previously discussed inability to make the parallel runspaces wait for the debugger to attach, at which statement in the runspace's code the debugger breaks is non-deterministic, and if the runspace has already terminated, the debugging attempt will fail (which also means that unless you have long-running runspaces, you may only get to debug one of them, if any).
    # Start a sample job with 3 parallel runspaces.
    $job = 1..3 | ForEach-Object -AsJob -Parallel { 
      # Dummy activity that last about two seconds, so that the 
      # debugger has a chance to attach.
      # Note that using `Wait-Debugger` does NOT currently work.
      $rs = [runspace]::DefaultRunspace
      1..20 | ForEach-Object { 
        $i = $_
        "Hi #$i from runspace $($rs.Name)"
        Start-Sleep -Milliseconds 100 
      }
    }
    
    # Wait until the job is ready to be debugged.
    while ($job.State -ne 'Running') { Start-Sleep -Milliseconds 50 }
    
    # !! Due to a bug, 'Running' is reported *too soon*.
    # !! The suboptimal workaround is to sleep a little.
    Start-Sleep -Milliseconds 100
    
    # Invoke the debugger:
    # Debug-Job can only debug one runspace at a time.
    # Each runspace runs in a *child* job. Here, the first one
    # is targeted.
    # !! It is non-deterministic at which statement the debugger will
    # !! break.
    # Submit 'd' or 'c' to exit the debugging session and continue running, 
    # 'h' to display help.
    $job.ChildJobs[0] | Debug-Job
    
    # After exiting the debugger, 
    # receive and output the job's output, then delete the job.
    $job | Receive-Job -Wait -AutoRemoveJob