Public/Start-EnvkAppBatch.ps1
|
#Requires -Version 5.1 <# .SYNOPSIS STA RunspacePool parallel app launcher for PowerShell 7+. .DESCRIPTION Start-EnvkAppBatch launches a batch of apps concurrently using an STA RunspacePool. STA (Single-Threaded Apartment) mode is required so that each worker thread can call UIAutomationClient (Invoke-WindowMaximize) without an InvalidOperationException. Each worker calls Invoke-AppWorker (Private), which runs the full per-app launch sequence (path validation, already-running check, Start-Process, Invoke-WindowMaximize) and collects all log messages in a buffered List[PSCustomObject]. Write-EnvkLog uses Add-Content which is not thread-safe; buffering avoids silent log entry drops under concurrent writes (PowerShell/PowerShell#14416). The caller (Invoke-EnvkStartup, Plan 03) is responsible for: - Calling Start-EnvkAppBatch once per priority group - Flushing $result.LogLines via Write-EnvkLog after each batch completes - Never calling Start-EnvkAppBatch on PS 5.1 (guarded internally; orchestrator checks PS version before selecting the parallel path) Execution flow: 1. Guard: throw terminating error if PS Major < 7. 2. Build InitialSessionState loading the Envoke module manifest. 3. Create RunspacePool(1, ThrottleLimit, iss, $Host). 4. Set pool.ApartmentState = STA BEFORE pool.Open(). 5. For each app: [PowerShell]::Create(), assign to pool, AddScript + AddArgument x4, BeginInvoke() -> collect [PSCustomObject]@{PS; Handle; AppName} in $inflight list. 6. EndInvoke each handle; check HadErrors/Streams.Error; Dispose each PS instance. 7. pool.Close()/Dispose() in finally. 8. Return results array (unary comma to prevent single-element unboxing). .PARAMETER Apps Array of resolved app PSCustomObjects (from Resolve-AppConfig) with properties: Name, Path, ProcessName, Arguments, SkipMaximize, Priority. .PARAMETER ThrottleLimit Maximum number of concurrent runspaces (from config parallelLimit, default 5). .PARAMETER WindowTimeoutMs Window detection timeout in milliseconds (from config windowTimeoutSeconds * 1000). .PARAMETER EnableDebug When set, passed through to Invoke-AppWorker/Invoke-WindowMaximize workers for detailed UIAutomation logging. Workers receive this as a plain [bool] parameter. Note: $script:Config['EnableDebug'] is intentionally set to $false in worker runspaces to suppress direct Write-EnvkLog calls from IWM/Get-MainWindow; Add-Content is not thread-safe and concurrent writes drop entries silently. All log output is buffered in result.LogLines and flushed sequentially by the parent. .OUTPUTS [PSCustomObject[]] Array of per-app result objects, one per input app, with: AppName [string] Status [string] -- 'Launched' | 'AlreadyRunning' | 'Failed' | 'Skipped' ElapsedMs [int] MaximizeResult [PSCustomObject] or $null ErrorMessage [string] or $null LogLines [System.Collections.Generic.List[PSCustomObject]] -- Level/Message entries .NOTES Author: Aaron AlAnsari Created: 2026-02-25 STA REQUIREMENT: UIAutomationClient requires STA mode. The pool.ApartmentState must be set to STA before pool.Open(). Setting it after Open() throws InvalidRunspacePoolStateException. ForEach-Object -Parallel and Start-ThreadJob both create MTA runspaces and cannot be used for UIAutomation work. MODULE LOADING: Parallel runspaces do not inherit the parent session's loaded modules. InitialSessionState.ImportPSModule() with the .psd1 manifest path loads the full module (including all Private functions, including Invoke-AppWorker) into each runspace. LOG BUFFERING: Write-EnvkLog uses Add-Content which is not thread-safe under concurrent writes (silently drops entries). Workers collect log lines in List[PSCustomObject] returned in the result object. The caller flushes them via Write-EnvkLog sequentially. WORKER FUNCTION: The per-app logic lives in Invoke-AppWorker (Private). The worker scriptblock calls Invoke-AppWorker after setting $script:GracePeriodMs. This separation allows the per-app logic to be tested with InModuleScope mocks independently of the RunspacePool infrastructure. #> # PSUseOutputTypeCorrectly: unary comma operator forces an Object[] wrapper around the # result array to prevent single-element unboxing in the PowerShell pipeline. # PSUseShouldProcessForStateChangingFunctions: Start-EnvkAppBatch delegates all state changes # to Start-Process (which natively supports -WhatIf) and Invoke-WindowMaximize. # Adding ShouldProcess here would leave $launchedPid null in -WhatIf mode. [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '')] [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] param () function Start-EnvkAppBatch { [CmdletBinding()] [OutputType([PSCustomObject[]])] param ( [Parameter(Mandatory)] [PSCustomObject[]]$Apps, [Parameter(Mandatory)] [int]$ThrottleLimit, [Parameter(Mandatory)] [int]$WindowTimeoutMs, [Parameter()] [switch]$EnableDebug ) # ------------------------------------------------------------------ # Guard: PS 7+ required for RunspacePool STA support. # ------------------------------------------------------------------ if ($PSVersionTable.PSVersion.Major -lt 7) { $err = [System.Management.Automation.ErrorRecord]::new( [System.InvalidOperationException]::new( "Start-EnvkAppBatch requires PowerShell 7 or later. Current version: $($PSVersionTable.PSVersion). " + "Use Start-EnvkAppSequence for PowerShell 5.1 sequential launching." ), 'PSVersionRequired', [System.Management.Automation.ErrorCategory]::InvalidOperation, $null ) $PSCmdlet.ThrowTerminatingError($err) } # ------------------------------------------------------------------ # Build InitialSessionState loading the Envoke module. # (Get-Module Envoke).ModuleBase resolves the module directory; # the manifest .psd1 is at $ModuleBase\Envoke.psd1. # ------------------------------------------------------------------ $moduleBase = (Get-Module Envoke).ModuleBase $manifestPath = Join-Path $moduleBase 'Envoke.psd1' $iss = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() $iss.ImportPSModule($manifestPath) # ------------------------------------------------------------------ # Create RunspacePool. ApartmentState MUST be set before Open(). # ------------------------------------------------------------------ $pool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool( 1, $ThrottleLimit, $iss, $Host ) $pool.ApartmentState = [System.Threading.ApartmentState]::STA $pool.Open() # Worker scriptblock: runs per-app inside each STA runspace. # Sets GracePeriodMs then delegates to Invoke-AppWorker (Private). # Log messages are buffered in the result object (not written to disk here). # # PRIVATE FUNCTION ACCESS: Invoke-AppWorker is a private module function and is # not exported from the module. The scriptblock runs in global scope (outside the # module scope), so a bare call to "Invoke-AppWorker" would fail with # "not recognized". Using "& (Get-Module Envoke) { ... }" executes the # body inside the module's own scope where private functions are accessible. $workerScript = { param( [PSCustomObject]$App, [int]$WorkerWindowTimeoutMs, [bool]$WorkerEnableDebug ) # Delegate to Invoke-AppWorker via module scope so the private function is accessible. # GracePeriodMs is set inside the module-scope block because Invoke-AppWorker reads # $script:GracePeriodMs from the module's own script scope, not the runspace global scope. & (Get-Module Envoke) { param($App, $WindowTimeoutMs, $EnableDebug) $script:GracePeriodMs = 2000 # Suppress direct Write-EnvkLog DEBUG writes in worker runspaces. # Worker runspaces load the module fresh; $script:Config is unset by default. # EnableDebug is passed to Invoke-AppWorker as a plain bool so it can forward # it to Invoke-WindowMaximize for detailed UIAutomation logging, but direct # Write-EnvkLog calls (from IWM/Get-MainWindow) must NOT write to the log file # here -- Add-Content is not thread-safe and concurrent writes silently drop # entries. All log output is buffered in result.LogLines and flushed # sequentially by the parent after each batch completes. if ($null -eq $script:Config) { $script:Config = @{} } $script:Config['EnableDebug'] = $false Invoke-AppWorker -App $App -WindowTimeoutMs $WindowTimeoutMs -EnableDebug $EnableDebug } $App $WorkerWindowTimeoutMs $WorkerEnableDebug } # ------------------------------------------------------------------ # Dispatch all apps asynchronously and collect results. # ------------------------------------------------------------------ $results = [System.Collections.Generic.List[PSCustomObject]]::new() $inflight = [System.Collections.Generic.List[PSCustomObject]]::new() try { # Fire all apps asynchronously. foreach ($app in $Apps) { $ps = [System.Management.Automation.PowerShell]::Create() $ps.RunspacePool = $pool [void]$ps.AddScript($workerScript) [void]$ps.AddArgument($app) [void]$ps.AddArgument($WindowTimeoutMs) [void]$ps.AddArgument([bool]$EnableDebug) $inflight.Add([PSCustomObject]@{ PS = $ps Handle = $ps.BeginInvoke() AppName = $app.Name }) } # Collect results -- EndInvoke blocks until each worker completes. foreach ($item in $inflight) { $output = $item.PS.EndInvoke($item.Handle) if ($item.PS.HadErrors) { # Worker-level error (exception escaped the worker). $workerError = $item.PS.Streams.Error | Select-Object -First 1 $errMsg = if ($null -ne $workerError) { $workerError.ToString() } else { 'Unknown worker error' } $errorLogLines = [System.Collections.Generic.List[PSCustomObject]]::new() $errorLogLines.Add([PSCustomObject]@{ Level = 'ERROR' Message = "Start-EnvkAppBatch: worker error for '$($item.AppName)' -- $errMsg" }) $results.Add([PSCustomObject]@{ AppName = $item.AppName Status = 'Failed' ElapsedMs = 0 MaximizeResult = $null ErrorMessage = $errMsg LogLines = $errorLogLines }) } elseif ($null -ne $output -and $output.Count -gt 0) { $results.Add($output[0]) } else { # Worker returned nothing (unexpected). $emptyLogLines = [System.Collections.Generic.List[PSCustomObject]]::new() $emptyLogLines.Add([PSCustomObject]@{ Level = 'ERROR' Message = "Start-EnvkAppBatch: worker returned no output for '$($item.AppName)'" }) $results.Add([PSCustomObject]@{ AppName = $item.AppName Status = 'Failed' ElapsedMs = 0 MaximizeResult = $null ErrorMessage = 'Worker returned no output' LogLines = $emptyLogLines }) } $item.PS.Dispose() } } finally { $pool.Close() $pool.Dispose() } return , $results.ToArray() } |