Private/Get-DescendantProcessIds.ps1

#Requires -Version 5.1
<#
.SYNOPSIS
    Returns all descendant process IDs for a given parent process.
 
.DESCRIPTION
    Recursively queries Win32_Process via CIM to collect all child and grandchild PIDs
    for a given parent process ID. Enforces a maximum recursion depth to prevent
    runaway traversal on pathological process trees. Stale PIDs (processes that exit
    mid-traversal) are skipped individually without failing the whole traversal.
 
    This is a private helper used by the Window Manager (Phase 5) to find windows
    of Electron apps (Teams, ClickUp) that spawn child processes.
 
.PARAMETER ParentId
    The process ID of the parent process whose descendants to collect.
 
.PARAMETER Depth
    Current recursion depth. Internal use only -- do not pass this parameter externally.
    Defaults to 0.
 
.PARAMETER MaxDepth
    Maximum recursion depth before traversal stops. Logs a WARNING when reached.
    Defaults to 8 (well above observed Electron app depth of 2-4 levels).
 
.OUTPUTS
    [int[]] -- Array of descendant process IDs (does not include the parent PID).
 
.NOTES
    Author: Aaron AlAnsari
    Created: 2026-02-25
#>


# PSUseSingularNouns: intentionally plural -- this function returns a collection of
# integer process IDs. The plural noun accurately describes the return value.
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')]
param ()

function Get-DescendantProcessIds {
    [CmdletBinding()]
    [OutputType([int[]])]
    param (
        [Parameter(Mandatory)]
        [int]$ParentId,

        [Parameter()]
        [int]$Depth = 0,

        [Parameter()]
        [int]$MaxDepth = 8
    )

    # Guard: depth limit -- log WARNING because this is abnormal in production.
    if ($Depth -ge $MaxDepth) {
        Write-EnvkLog -Level 'WARNING' -Message "Get-DescendantProcessIds: max depth $MaxDepth reached at PID $ParentId -- stopping traversal"
        return [int[]]@()
    }

    # Use List[int] to avoid O(n^2) array-concatenation on large process trees.
    $descendantIds = [System.Collections.Generic.List[int]]::new()

    # Query direct children. ErrorAction SilentlyContinue returns $null (not an
    # exception) when the parent PID has already exited.
    $children = Get-CimInstance -ClassName Win32_Process -Filter "ParentProcessId=$ParentId" -ErrorAction SilentlyContinue

    Write-EnvkLog -Level 'DEBUG' -Message "Get-DescendantProcessIds: found $(@($children).Count) children of PID $ParentId at depth $Depth"

    foreach ($child in $children) {
        $childPid = [int]$child.ProcessId
        $descendantIds.Add($childPid)

        # Wrap each child's recursion individually so a stale PID mid-traversal
        # does not stop collection of its siblings.
        try {
            $grandchildren = Get-DescendantProcessIds -ParentId $childPid -Depth ($Depth + 1) -MaxDepth $MaxDepth
            foreach ($gc in $grandchildren) {
                $descendantIds.Add($gc)
            }
        }
        catch {
            Write-EnvkLog -Level 'DEBUG' -Message "Get-DescendantProcessIds: skipped PID $childPid -- process exited during traversal ($($_.Exception.Message))"
        }
    }

    return $descendantIds.ToArray()
}