Automatically Removing a PowerShell Job when it has finished (asynchronously) - powershell

I have a PowerShell cmdlet which I use to simplify connecting to another computer with RDP.
Within that cmdlet I run do the following:
if ($null -ne $Username -and $null -ne $Password) {
Start-Process -FilePath "cmdkey.exe" -ArgumentList #("/generic:`"TERMSRV/$ComputerName`"", "/user:`"$Username`"", "/pass:`"$Password`"") -WindowStyle Hidden
}
Start-Job -ScriptBlock {
param($InstallPath, $ComputerName, $Port, $Username, $Password)
$arguments = #("`"$(Join-Path $InstallPath '\Support Files\MSTSC\Default.rdp')`"")
if ($null -ne $Port) {
$arguments += "/v:`"$($ComputerName):$($Port)`""
} else {
$arguments += "/v:`"$($ComputerName)`""
}
Start-Process -FilePath "mstsc.exe" -ArgumentList $arguments -Wait
if ($null -ne $Username -and $null -ne $Password) {
Start-Process -FilePath "cmdkey.exe" -ArgumentList #("/delete:`"TERMSRV/$ComputerName`"") -WindowStyle Hidden
}
} -ArgumentList #($InstallPath, $ComputerName, $Port, $Username, $Password)
As you can see I add the credentials used to connect to the remote machine, then start a job which executes mstsc.exe, waits for it to finish then removes the credentials.
The problem is I have to wait for mstsc to close before deleting the credentials, as otherwise they get removed before mstsc has a chance to establish the connection and I want this cmdlet to be self contained - returning control immediately to the users command prompt so I can run other commands while I am also using the RDP session which means I can't wait for the job to finish as that mean I am stuck waiting until I disconnect from the remote session:
| Wait-Job | Remove-Job
What I want to do is to be able to Remove-Job once it has completed, perhaps using some kind of callback so I don't have to manually run another command to clean up the Job once I log out of the RDP session and the Job isn't left in a Completed state (which is what I am currently doing but obviously this isn't 'clean).
For the full cmdlet you can see it here for more context:
https://github.com/paulmarsy/Console/blob/master/AdvancedPowerShellConsole/Exports/Functions/Connect-Remote.ps1

I know this is a very old question... You can use Register-ObjectEvent to clean up after jobs. Jobs have a StateChanged event that has an EventSubscriber parameter passed to it containing details of the event and the source job.
Here's an example. Once the job completes the callback will remove both itself and the source job.
$job = Start-Job { Start-Sleep -Seconds 2 }
Register-ObjectEvent -InputObject $job -EventName StateChanged -Action {
Unregister-Event $EventSubscriber.SourceIdentifier
Remove-Job $EventSubscriber.SourceIdentifier
Remove-Job -Id $EventSubscriber.SourceObject.Id
} | Out-Null

So register a scheduledjob to run in 5 minutes that will remove the completed job. I am pretty sure that you can do something like:
Register-ScheduledJob -ScriptBlock {param($computername); Wait-Job -Name $ComputerName|remove-job} -Trigger #{Frequency="Once";At=(get-date).AddMinutes(5).ToString("h:MM tt")} -argumentlist $computername
Then just give your connection a name when you do your Start-Job by appending -Name $ComputerName to the end of the command. That way 5 minutes after you launch it a scheduled task kicks off that finds and clears out that job by name.

Related

Powershell Execute program in parallel and wait end of execution

I need to execute a program (.exe) in a powershell script in a foreach loop, I need to wait the end of execution before doing some other tasks.
I tried this solution, the program is launched but it's closing immediately
$jobArray = New-Object -TypeName System.Collections.ArrayList
ForEach ($item in Get-Content C:\items.txt) {
$job = Start-Job -ScriptBlock {Start-Process "C:\Development\Console.exe" -ArgumentList /count, /Id:$item, /verbose }
$jobArray.Add($job)
}
Write-Verbose "started" -Verbose
#Wait for all jobs
ForEach ($job in $jobArray) {
$job | Wait-Job
}
A process already is like a job, it runs in parallel. In fact, PowerShell jobs are also just processes, so you currently start a process just to start another process.
Use Start-Process -PassThru without -Wait to capture a process object on which you can wait using Wait-Process.
$processes = ForEach ($item in Get-Content C:\items.txt) {
Start-Process -PassThru -FilePath 'C:\Development\Console.exe' -ArgumentList '/count', "/Id:$item", '/verbose'
}
Write-Verbose "started" -Verbose
# Wait for all processes
$processes | Wait-Process
As another optimization, most of the times you use a foreach or ForEach-Object loop you don't need to use ArrayList explicitly. Just assign the loop statement or pipeline to a variable and let PowerShell create an array for you in an efficient way.

Adobe flash player powershell remote install problem

I'm attempting to develop a script with PowerShell to remotely install/update flash player for multiple machines. No matter what I do, I can't get the install to work properly at all. I'm very limited with my tools so I have to use PowerShell, and the MSI install of Flashplayer. I'll post my script below, any help at all would be greatly appreciated.
$Computers = Get-Content C:\Users\name\Desktop\flash.txt
(tried these 3 methods to install none work)
$install = #("/a","/i", "\\$Computer\c$\temp\flash\install_flash_player_32_plugin.msi", "/qn","/norestart")
Invoke-Command -ComputerName $Computer -ScriptBlock {Start-Process "Msiexec" -arg "$using:install" -Wait -PassThru} -Filepath msiexec.exe
#This returns with "invoke-command: parameter set cannot be resolved using the specified named parameters"
Invoke-Command -ComputerName $computer -ScriptBlock {Start-Process -Filepath msiexec.exe "$using:install" -Wait -PassThru} -Filepath msiexec.exe
#this returns the same error.
Invoke-Command -ComputerName $Computer -ScriptBlock {start-process msiexec -argumentlist #('/a','/i','"\\$Computer\c$\temp\flash\install_flash_player_32_plugin.msi"','/qn')}
#this seemingly skips the install entirely.
I've used similar scripts for other programs and had no problems installing them, but none of the methods I use or have researched are working properly.
This should do the trick, I'll explain why it wasn't working bellow:
$Computers = Get-Content C:\Users\name\Desktop\flash.txt
$params = '/i <path to AcroPro.msi> LANG_LIST=en_US TRANSFORMS="1033.mst" /qb'
$Computers | % {
Invoke-Command -ScriptBlock {
Param(
[Parameter(Mandatory=$true,Position=0)]
[String]$arguments
)
return Start-Process msiexec.exe -ArgumentList $arguments -Wait -PassThru
} -ComputerName $_ -ArgumentList $params
}
So, it wasn't working because the ScriptBlock on Invoke-Command cant see variables that you've declared on your powershell session, think of it like you are walking to that remote computer and inputting that code by hand, you wont have the value (metaphor).
I did a few more changes:
I moved all params into 1 single string, no need to have them in array.
Added $Computers | to iterate through computer names.
Removed FilePath as this is meant to be used differently, documentation(Example #1).
Set $MinutesToWait to whatever amount of minutes you want.
No need to try to pass msiexec, as it comes with windows the default path is "C:\WINDOWS\system32\msiexec.exe"
Added a return even though its never necessary, but to make it more readable and to show you intent to return the output of the msiexec process.
Replaced \\$Computer\c$ with C:\ as there's no need to use a network connection if you are pointing to the host you are running the command in/
Hope it helps, good luck.
EDIT:
So, as you mentioned the pipeline execution gets stuck, I had this issue in the past when creating the computer preparation script for my department, what I did was use jobs to create parallel executions of the installation so if there's a computer that for some reason is slower or is just flat out stuck and never ends you can identify it, try the following as is to see how it works and then do the replaces:
#region ######## SetUp #######
$bannerInProgress = #"
#######################
#Jobs are still running
#######################
"#
$bannerDone = #"
##################################################
#DONE see results of finished installations bellow
##################################################
"#
#VARS TO SET
$MinutesToWait = 1
$computers = 1..10 | % {"qwerty"*$_} #REPLACE THIS WITH YOUR COMPUTER VALUES (Get-Content C:\Users\name\Desktop\flash.txt)
#endregion
#region ######## Main #######
#Start Jobs (REPLACE SCRIPTBLOCK OF JOB WITH YOUR INVOKE-COMMAND)
$jobs = [System.Collections.ArrayList]::new()
foreach($computer in $computers){
$jobs.Add(
(Start-Job -Name $computer -ScriptBlock {
Param(
[Parameter(Mandatory=$true, Position=0)]
[String]$computer
)
Sleep -s (Get-Random -Minimum 5 -Maximum 200)
$computer
} -ArgumentList $computer)
) | Out-Null
}
$timer = [System.Diagnostics.Stopwatch]::new()
$timer.Start()
$acceptedWait = $MinutesToWait * 60 * 1000 # mins -> sec -> millis
$running = $true
do {
cls
$jobsRunning = $jobs | Where-Object State -EQ 'Running'
if ($jobsRunning) {
Write-Host $bannerInProgress
foreach ($job in $jobsRunning) {
Write-Host "The job `"$($job.Name)`" is still running. It started at $($job.PSBeginTime)"
}
Sleep -s 3
} else {
$running = $false
}
if($timer.ElapsedMilliseconds -ge $acceptedWait){
$timer.Stop()
Write-Host "Accepted time was reached, stopping all jobs still pending." -BackgroundColor Red
$failed = #()
foreach($job in $jobsRunning){
$output = $job | Receive-Job
$failed += [PsCustomObject]#{
"ComputerName" = $job.Name;
"Output" = $output;
}
$job | Remove-Job -Force
$jobs.Remove($job)
}
$failed | Export-Csv .\pendingInstallations.csv -NoTypeInformation -Force
$running = $false
}
}while($running)
Write-host $bannerDone
$results = #()
foreach($job in $jobs){
$output = $job | Receive-Job
$results += [PsCustomObject]#{
"ComputerName" = $job.Name;
"Output" = $output;
}
}
$results | Export-Csv .\install.csv -NoTypeInformation -Force
#endregion
This script will trigger 10 jobs that only wait and return its names, then the jobs that got completed in the time that you set are consider correct and the ones that didn't are consider as pending, both groups get exported to a CSVfor review. You will need to replace the following to work as you intended:
Add $params = '/i <path to AcroPro.msi> LANG_LIST=en_US TRANSFORMS="1033.mst" /qb' in the SetUp region
Replace the declaration of $computers with $computers = Get-Content C:\Users\name\Desktop\flash.txt
Replace the body of Start-Job scriptblock with Invoke-command from thew first snippet of code in this answer.
you should end-up with something like:
.
.code
.
$params = '/i <path to AcroPro.msi> LANG_LIST=en_US TRANSFORMS="1033.mst" /qb'
#VARS TO SET
$MinutesToWait = 1
$computers = Get-Content C:\Users\name\Desktop\flash.txt
#endregion
#region ######## Main #######
#Start Jobs
$jobs = [System.Collections.ArrayList]::new()
foreach($computer in $computers){
$jobs.Add(
(Start-Job -Name $computer -ScriptBlock {
Param(
[Parameter(Mandatory=$true, Position=0)]
[String]$computer
)
Invoke-Command -ScriptBlock {
Param(
[Parameter(Mandatory=$true,Position=0)]
[String]$arguments
)
return Start-Process msiexec.exe -ArgumentList $arguments -Wait -PassThru
} -ComputerName $computer -ArgumentList $params
} -ArgumentList $computer)
) | Out-Null
}
.
. code
.
I know it looks like a complete mess, but it works.
Hope it helps.

Script-Block parsing issues

I am running some code that is read from an encrypted file and converted into a ScriptBlock. The code will be a full complex script, but for simplicity let's assume it is the following:
"$(date) Agent started." | Out-File -FilePath 'C:\TMP\test_agent.log' -Append
while($true) {
'$(date) Will check back in 30 seconds...' | Out-File -FilePath 'C:\TMP\test_agent.log' -Append
Start-Sleep -Seconds 30
}
Below is the simple code that launches it, and it works just fine (the $sStr variable contains the above script as a string):
$job = Start-Job -ScriptBlock {
$sb = $executioncontext.invokecommand.NewScriptBlock($args[0])
Invoke-Command -ScriptBlock $sb
} -ArgumentList $sStr | Wait-Job -Timeout 1 | Receive-Job
Again, this works fine. However, I need this to run as a new PowerShell process. But when I try the below the ScriptBlock is not parsed properly and I get errors. Here is the modified launcher:
$job = Start-Job -ScriptBlock {
$sb = $executioncontext.invokecommand.NewScriptBlock($args[0])
powershell "Invoke-Command -ScriptBlock $sb"
} -ArgumentList $sStr | Wait-Job -Timeout 1 | Receive-Job
How to start a new powershell process (and kill the parent) so that the ScriptBlock is correctly parsed and executed?
Thanks!
Maybe you could solve your problem with a PowerShell background job, which can be created via Start-Job. Start-Job also has a -ScriptBlock parameter.
Example 7 of Get-Job shows you how to check if the job is already completed, and how you can retrieve the results of the job.
Hope that helps.

Run console EXE remotely with PowerShell and get its process ID and stdout

I use PowerShell to run a console EXE on a remote server and see its output like this:
Invoke-Command -ComputerName %SERVER_NAME% -ScriptBlock { Set-Location "%EXE_DIR%" ; .\MyExe.exe %EXE_ARGS% }
This works, but gives me no way to kill the process, except connecting to the server via RDP. If I could just save its process ID to a file I should be able to kill it using Stop-Process. I tried this:
Invoke-Command -ComputerName %SERVER_NAME% -ScriptBlock { Start-Process -NoNewWindow -PassThru -Wait -WorkingDirectory "%EXE_DIR%" "%EXE_DIR%\MyExe.exe" "%EXE_ARGS%" }
The process runs, but now I don't see its standard output! When I use Start-Process locally (without Invoke-Command) I see the output.
How can I get both the process ID and the standard output/error?
Use a background job. You can either start a local job to invoke a command on a remote host:
$job = Start-Job -ScriptBlock {
Invoke-Command -ComputerName $computer -ScriptBlock {
Set-Location 'C:\some\folder'
& 'C:\path\to\your.exe' $args
} -ArgumentList $args
} -ArgumentList $argument_list
or open a session to a remote host and start a local job there:
Enter-PSSession -Computer $computer
$job = Start-Job -ScriptBlock {
Set-Location 'C:\some\folder'
& 'C:\path\to\your.exe' $args
} -ArgumentList $argument_list
Exit-PSSession
Job output can be received via Receive-Job:
while ($job.HasMoreData) {
Receive-Job $job
Start-Sleep -Milliseconds 100
}
You can terminate it via its StopJob() method:
$job.StopJob()
Remove-Job removes the job from the job list:
Remove-Job $job

Wait for process to end

I have a powershell script that will automatically print all .pdf files in a folder with Foxit. I have it set to wait until Foxit has exited until the script continues. Every once in a while, the script will pause and wait even though Foxit has already exited. Is there a way to make it time out after a certain amount of time?
Here is the code I have:
Start-Process $foxit -ArgumentList $argument -Wait
Move-Item $filePath $printed[$location]
Add-Content "$printLogDir\$logFileName" $logEntry
I've tried the recommendations here and they don't seem to work. For example if I do:
$proc = Start-Process $foxit -ArgumentList $argument
$proc.WaitForExit()
Move-Item $filePath $printed[$location]
Add-Content "$printLogDir\$logFileName" $logEntry
I get:
You cannot call a method on a null-valued expression.
Any help would be greatly appreciated.
I think I figured it out. If I start it with Invoke-WmiMethod I can get the process ID and wait for it, then ask it to time out.
$proc = Invoke-WmiMethod -Class win32_process -Name create -ArgumentList "$foxit $argument"
Wait-Process -Id $proc.ProcessId -Timeout 120
Move-Item $filePath $printed[$location]
Add-Content "$printLogDir\$logFileName" $logEntry
This seems to work pretty consistantly.
One way to implement a timeout is to use a background job:
$Job = start-job -ScriptBlock { write-output 'start';Start-Sleep -Seconds 15 }
$timeout = New-TimeSpan -Seconds 10
$timer = [diagnostics.stopwatch]::StartNew()
While ($timer.Elapsed -le $timeout)
{
if ($Job.State -eq 'Completed' )
{
Receive-Job $Job
Remove-Job $Job
Return
}
Start-Sleep -Seconds 1
}
write-warning 'Job timed out. Stopping job'
Stop-Job $Job
Receive-Job $Job
Remove-Job $Job
I ran into this same problem and found a slightly simpler solution that also captures the output of the child process. The -PassThru argument is key as it returns a process object for each process that the cmdlet starts.
$proc = Start-Process $foxit -ArgumentList $argument -PassThru
Wait-Process -Id $proc.Id -Timeout 120
Move-Item $filePath $printed[$location]
Add-Content "$printLogDir\$logFileName" $logEntry
I prefer this style to have better control what to do during the runtime of the external process:
$waitTime = 60
$dism = Start-Process "$env:windir\system32\dism.exe" -ArgumentList "/Online /Cleanup-Image /ScanHealth" -PassThru -WindowStyle Hidden
while (!$dism.HasExited -or $waitTime -gt 0) {sleep -Seconds 1; $waitTime--}
"done."
By cheking the HasExited-attribute I can continue with my code with any other tasks in parallel.