I'm relatively new to PowerShell and I'm trying to create a function to build some installers. I have it working to build them in series but it would be nice to run in parallel, but it also needs to wait until they are all built to continue. I believe this would be done with Start-Job
and Wait-Job
but I can't get it working correctly. Here is my current code:
function BuildInstallers {
$installerPath = $env:ADVINST_COM
$jobs = @();
for ($year = 2023; $year -le 2026; $year++) {
$jobs += Start-Job -ScriptBlock { Start-Process -FilePath $using:installerPath -ArgumentList "/rebuild `"Installers\install$using:year.aip`"" -Wait -NoNewWindow }
}
Wait-Job -Job $jobs
Receive-Job -Job $jobs
}
When I run this it runs but just immediately gives me this output:
Id Name PSJobTypeName State HasMoreData Location Command
-- ---- ------------- ----- ----------- -------- -------
1 Job1 BackgroundJob Completed False localhost Start-Process -FilePa...
3 Job3 BackgroundJob Completed False localhost Start-Process -FilePa...
5 Job5 BackgroundJob Completed False localhost Start-Process -FilePa...
7 Job7 BackgroundJob Completed False localhost Start-Process -FilePa...
It's basically saying the processes are instantly completed but nothing happens. What I would like to happen is that it spins up a process for each installer and starts running them in parallel and then when all are complete it continues execution (returns from this function). What am I doing wrong?
EDIT
I neglected to mention that the path to the installer executable resolves to a .com file instead of a .exe (that's what the company says to call for scripts). The full current path is C:\Program Files (x86)\Caphyon\Advanced Installer 22.6\bin\x86\AdvancedInstaller.com
Preface:
Your symptoms suggests that the installer invocations terminate prematurely, which would require further troubleshooting.
The alternative approach below - which is generally preferable - may help you with troubleshooting.
-Parallel
parameter of the ForEach-Object
cmdlet, which in essence runs thread-based jobs in parallel, with each pipeline input object being processed in its own thread; see this answer and the docs for more information.Instead of using Start-Process
in your background jobs, invoke each instance of the installer directly; not only does this potentially capture its standard output streams (stdout and stderr), it also reflects its (process') exit code in the automatic $LASTEXITCODE
variable on termination.[1]
Write-Output
to ensure synchronous execution, as shown below. For console-based installers, this isn't necessary.The following is a streamlined version of your code that implements the above recommendations; it outputs information about each of the completed jobs in the form of a [pscustomobject]
instance.
function BuildInstallers {
$installerPath = $env:ADVINST_COM
# Launch the installers asynchronously as jobs,
# each of which runs the installer synchronously, in the same
# directory as this script ($PSScriptRoot)
$jobs = 2023..2026 | ForEach-Object {
Start-Job -Name $_ -WorkingDirectory $PSScriptRoot {
& $using:installerPath /rebuild "Installers\install$using:_.aip" | Write-Output
$LASTEXITCODE # Output the installer's exit code.
}
}
# Wait for the jobs to complete and relay their output.
$jobs | Wait-Job | ForEach-Object {
$output = $_ | Receive-Job -Wait -AutoRemoveJob
$exitCode = $output[-1]
[pscustomobject] @{
Year = $_.Name
ExitCode = $exitCode
Output = ($output | Select-Object -SkipLast 1) -join "`n"
}
}
}
Note:
-WorkingDirectory $PSScriptRoot
is passed to Start-Job
to ensure that each job runs in the directory in which the calling script is located, so that the relative paths to the installers are resolved correctly; note that the default working directory for jobs in Windows PowerShell (the legacy, ships-with-Windows, Windows-only edition of PowerShell whose latest and last version is 5.1) is the user's Documents folder, whereas in PowerShell (Core) 7 jobs inherit the caller's working directory.
When calling BuildInstallers
and printing output to the display, consider piping to Format-Table -Wrap
or Format-List
to make sure that the captured installer output displays in full (albeit potentially with line breaks inserted so as not to exceed the terminal width).
Potential alternative:
Start-Process
is asynchronous by default (-Wait
makes it synchronous), so you can alternatively make multiple calls directly - no strict need for jobs:
Doing so allows you to run even GUI-based applications invisibly, by adding -WindowStyle Hidden
Note that console-based applications run invisibly by default in background jobs (because they run in the same hidden console that the job runs in).
Using background jobs allows you to collect the stdout and stderr output from console-based applications.
Also querying the process exit code requires the approach shown above.
Allowing querying the exit code more easily, as part of the jobs infrastructure, is the subject of GitHub issue #5422
However, using Start-Process
doesn't allow you to directly capture stdout and stderr output from console-based applications (you'd have to write such output to files, using the RedirectStandardOutput
and RedirectStandardError
parameters).
In order to determine the process exit code of Start-Process
-launched processes you'll have to:
Pass -PassThru
to Start-Process
in order to emit a process-information object that refers to the newly launched process.
Call .WaitForExit()
on that object to await the process' termination.
Thereafter, query the object's .ExitCode
property.
[1] See this answer for a comprehensive juxtaposition of direct invocation vs. use of Start-Process
.