How to call a powershell function within the script from Start-Job? - powershell

I saw this question and this question but couldn't find solution to my problem.
So here is the situation:
I have two functions DoWork and DisplayMessage in a script (.ps1) file. Here is the code:
### START OF SCRIPT ###
function DoWork
{
$event = Register-EngineEvent -SourceIdentifier NewMessage -Action {
DisplayMessage($event.MessageData)
}
$scriptBlock = {
Register-EngineEvent -SourceIdentifier NewMessage -Forward
$message = "Starting work"
$null = New-Event -SourceIdentifier NewMessage -MessageData $message
### DO SOME WORK HERE ###
$message = "Ending work"
$null = New-Event -SourceIdentifier NewMessage -MessageData $message
Unregister-Event -SourceIdentifier NewMessage
}
DisplayMessage("Processing Starts")
$array = #(1,2,3)
foreach ($a in $array)
{
Start-Job -Name "DoActualWork" $ScriptBlock -ArgumentList $array | Out-Null
}
#$jobs = Get-Job -Name "DoActualWork"
While (Get-Job -Name "DoActualWork" | where { $_.State -eq "Running" } )
{
Start-Sleep 1
}
DisplayMessage("Processing Ends")
Get-Job -Name "DoActualWork" | Receive-Job
}
function DisplayMessage([string]$message)
{
Write-Host $message -ForegroundColor Red
}
DoWork
### END OF SCRIPT ###
I am creating 3 background jobs (using $array with 3 elements) and using event to pass messages from background jobs to the host. I would expect the powershell host to display "Processing Starts" and "Processing Ends" 1 time and "Starting work" and "Ending work" 3 times each. But instead I am not getting "Starting work"/"Ending work" displayed in console.
Event is also treated as a job in powershell, so when I do Get-Job, I can see following error associated with the event job:
{The term 'DisplayMessage' is not recognized as the name of a cmdlet,
function, script file, or operable program. Check the spelling of the
name, or if a path was included, verify that the path is correct and
try again.}
My question: How can we reuse (or reference) a function (DisplayMessage in my case) defined in the same script from where I am calling Start-Job? Is it possible?
I know we can use -InitializationScript to pass functions/modules to Start-Job, but I do not want to write DisplayMessage function twice, one in script and another in InitializationScript.

An easy way to include local functions in a background job:
$init=[scriptblock]::create(#"
function DoWork {$function:DoWork}
"#)
Start-Job -Name "DoActualWork" $ScriptBlock -ArgumentList $array -InitializationScript $init | Out-Null

Background jobs are run in a seperate process, so the jobs you create with Start-Job can not interact with functions unless you include them in the $scriptblock.
Even if you included the function in the $scripblock, Write-Host would not output it's content to the console until you used Get-Job | Receive-Job to recieve the jobs result.
EDIT The problem is that your DisplayMessage function is in a local script-scope while your eventhandler runs in a different parent scope(like global which is the session scope), so it can't find your function. If you create the function in the global scope and call it from the global scope, it will work.
I've modified your script to do this now. I've also modified to scriptblock and unregistered the events when the script is done so you won't get 10x messages when you run the script multiple times :-)
Untitled1.ps1
function DoWork
{
$event = Register-EngineEvent -SourceIdentifier NewMessage -Action {
global:DisplayMessage $event.MessageData
}
$scriptBlock = {
Register-EngineEvent -SourceIdentifier NewMessage -Forward
$message = "Starting work $args"
$null = New-Event -SourceIdentifier NewMessage -MessageData $message
### DO SOME WORK HERE ###
$message = "Ending work $args"
$null = New-Event -SourceIdentifier NewMessage -MessageData $message
Unregister-Event -SourceIdentifier NewMessage
}
DisplayMessage("Processing Starts")
$array = #(1,2,3)
foreach ($a in $array)
{
Start-Job -Name "DoActualWork" $ScriptBlock -ArgumentList $a | Out-Null
}
#$jobs = Get-Job -Name "DoActualWork"
While (Get-Job -Name "DoActualWork" | where { $_.State -eq "Running" } )
{
Start-Sleep 1
}
DisplayMessage("Processing Ends")
#Get-Job -Name "DoActualWork" | Receive-Job
}
function global:DisplayMessage([string]$message)
{
Write-Host $message -ForegroundColor Red
}
DoWork
Get-EventSubscriber | Unregister-Event
Test
PS > .\Untitled1.ps1
Processing Starts
Starting work 1
Starting work 2
Ending work 1
Ending work 2
Starting work 3
Ending work 3
Processing Ends

I got it to work as follows;
function CreateTable {
$table = "" | select ServerName, ExitCode, ProcessID, StartMode, State, Status, Comment
$table
}
$init=[scriptblock]::create(#"
function CreateTable {$function:createtable}
"#)

Related

Can't call a class method inside a `Register-ObjectEvent` scriptblock

My goal is to call a class method when the job received or simply to notify outside method that the job is complete.
Is there any way to do this? This is the sample script:
class Test {
$_Trigger
$_Event
$_Job
$_Result
Start() {
$this._Job = Start-Job -Name JobTest -ScriptBlock { Start-Sleep -Seconds 2; return 100; }
$this._Event = Register-ObjectEvent $this._Job StateChanged -Action {
$script:_Result = Receive-Job -Id $sender.Id -Keep
$this.Trigger() # this line here
}
}
Trigger() {
<#
CALL OTHER METHODS
#>
Write-Host "job complete"
}
}
I also try passing the method when calling the job like this
Start-Job -Name JobTest -ScriptBlock { Start-Sleep -Seconds 2; return 100; } -ArgumentList $this._Trigger
and want to access it via $Sender since there is the $sender variable as stated by the docs, but my problem is how to call it or how can I "view" this automatic variables inside the script block?
The value of the Action parameter can include the $Event, $EventSubscriber, $Sender, $EventArgs, and $Args automatic variables. These variables provide information about the event to the Action script block. For more information, see about_Automatic_Variables.
A couple of things.
If you want to debug your script or just your script to work when not ran from VsCode (eg: in a production context), you need to make sure that your script do not exit too soon. The simplest way to make sure this does not happen is to have some sort of loop at the end.
If you attempted to place a breakpoint within the Register-ObjectEvent -Action scriptblock and did have a message stating that the "breakpoint could not be hit", this is because your script already had exited and the debugger detached at the moment when you reached the actual event handler. Since VsCode maintain the session even when your script exited, the event handler will trigger, but that won't be a debuggable version.
$this will be $null from within the Register-ObjectEvent -Action scriptblock context. You need to pass the class as message data -MessageData #{This = $this } and access it from within the action block as $Event.MessageData.This
You don't have any Try/Catch in the Action scriptblock. Just be aware that any error will kill the event handler and it won't trigger afterward if any error is thrown.
Here is your example, adapted to fix these issues.
(Note: I also added Cyan)
class Test {
$_Trigger
$_Event
$_Job
$_Result
Start() {
$this._Job = Start-Job -Name JobTest -ScriptBlock { Start-Sleep -Seconds 2; return 100; }
$this._Event = Register-ObjectEvent $this._Job StateChanged -Action {
try {
$MyObject = $Event.MessageData.This
$MyObject._Result = Receive-Job -Id $sender.Id -Keep
$MyObject.Trigger() # this line here
}
catch {
}
} -MessageData #{This = $this }
}
Trigger() {
<#
CALL OTHER METHODS
#>
Write-Host "job complete" -ForegroundColor Cyan
}
}
$MyTestInstance = [Test]::new()
$MyTestInstance.Start()
# IMPORTANT
# You need some kind of loop to prevent the script of exiting immediately.
# If you don't have any, It will appear to work in vscode but won't work in production contexts
# You also won't be able to debug the code if the script debugger exitted
# We stay in the loop until we have a result.
while (!$$MyTestInstance._Result) {
Start-Sleep -Milliseconds 100
}
References
VsCode and Powershell: How to debug and trace into code called by Register-ObjectEvent
Register-ObjectEvent
About Automatic Variables
Note: See Sage Pourpre's helfpul answer for additional information.
Script blocks passed to the -Action parameter of Register-ObjectEvent run in a dynamic module, which doesn't have access to the caller's scope.
However, you can pass arbitrary data to the script block via the -MessageData parameter, which you can access via $Event.MessageData there, so you can pass the custom-class instance at hand as -MessageData $this:
class Test {
$_Event
$_Job
$_Result
Start() {
$this._Job = Start-Job -Name JobTest -ScriptBlock { Start-Sleep -Seconds 2; return 100; }
# Note the use of -MessageData and $Event.MessageData
$this._Event =
Register-ObjectEvent $this._Job StateChanged -MessageData $this -Action {
$Event.MessageData._Result = Receive-Job -Id $sender.Id -Keep
$Event.MessageData.Trigger() # this line here
}
}
Trigger() {
<#
CALL OTHER METHODS
#>
Write-Host "job complete"
}
}

Powershell Register-Object

I have a problem in running the below Powershell script in console.
function startMonitor {
$null = Register-ObjectEvent -InputObject ([Microsoft.Win32.SystemEvents]) -EventName "SessionSwitch" -Action {
switch($event.SourceEventArgs.Reason) {
'SessionLock'
{
---------do something----
}
'SessionUnlock'
{
--------do something----
}
}
}
}
startMonitor
When I run this in powershell ISE it works fine and output is as expected. When the session is locked or unlocked, output is generated perfectly.
But I want to run this as a script that starts up during logon.
I put this script in the startup folder as
powershell.exe -noexit -windowstyle hidden "E:\sources\lock.ps1"
The script runs fine. But, it does not generate the output (the other functions in this code generates output properly, except this function). When I try the command (without the switch -windowstyle):
Get-EventSubscriber
Shows that event is registered.
How do I run this script using the powershell.exe -noexit?
I am unable to use task scheduler for this purpose because of limitations in my environment.
In order to run in background you have to keep it within a loop and check for events, test with this little example...
[System.Reflection.Assembly]::LoadWithPartialName("System.Diagnostics")
# ------ init ------
function Start-mySession {
$null = Register-ObjectEvent -InputObject ([Microsoft.Win32.SystemEvents]) -EventName "SessionSwitch" -Action {
add-Type -AssemblyName System.Speech
$synthesizer = New-Object -TypeName System.Speech.Synthesis.SpeechSynthesizer
switch($event.SourceEventArgs.Reason) {
'SessionLock' { $synthesizer.Speak("Lock!") }
'SessionUnlock' { $synthesizer.Speak("Unlock!") }
}
}
}
# ------ stop ------
function End-mySession {
$events = Get-EventSubscriber | Where-Object { $_.SourceObject -eq [Microsoft.Win32.SystemEvents] }
$jobs = $events | Select-Object -ExpandProperty Action
$events | Unregister-Event
$jobs | Remove-Job
}
# ===== keep process in alive for 10 hours =====
[TimeSpan]$GLOBAL:timeout = new-timespan -Hours 10
$GLOBAL:sw = [diagnostics.stopwatch]::StartNew()
while ($GLOBAL:sw.elapsed -lt $GLOBAL:timeout){
[Array]$GLOBAL:XX_DEBUG_INFO += "DEBUG: initialize debug!"
# Loop
Start-mySession
start-sleep -seconds 10
#.. do something, or check results
End-mySession
}
# teardown after timeout
Write-Output "INFO: $( get-date -format "yyyy-MM-dd:HH:mm:ss" ) : $env:USERNAME : Timeout (logout/timout)"
End-mySession

Powershell Start-job synchronous output

I have a powershell script that starts a job
start-job -scriptblock {
while($true) {
echo "Running"
Start-Sleep 2
}
}
and then it continues executing the rest of the script.
That job, is kind of a monitoring one for the PID of that process.
I would like to synchronously print the PID every n seconds, without having to end the job.
For example, as the rest of the script is being executed, i would like to see output in my console.
Is something like that possible in powershell?
Thanks.
Yes, you can use events:
$job = Start-Job -ScriptBlock {
while($true) {
Register-EngineEvent -SourceIdentifier MyNewMessage -Forward
Start-Sleep -Seconds 3
$null = New-Event -SourceIdentifier MyNewMessage -MessageData "Pingback from job."
}
}
$event = Register-EngineEvent -SourceIdentifier MyNewMessage -Action {
Write-Host $event.MessageData;
}
for($i=0; $i -lt 10; $i++) {
Start-Sleep -Seconds 1
Write-Host "Pingback from main."
}
$job,$event| Stop-Job -PassThru| Remove-Job #stop the job and event listener
Credit goes to this answer. Other useful links:
How to Get Windows PowerShell to Notify You When a Job is Complete
Manage Event Subscriptions with PowerShell - Hey, Scripting Guy! Blog

Powershell script works interactively, not within a script

Code works interactively, in a PS1 file it does not. to reproduce, open powershell, paste the function and then run get-job to see the task. type get-job | remove-job when done and then put code in a PS1 file, it only runs the first two, then exits.
function RunJobFromQueue
{
if( $queue.Count -gt 0)
{
$cn = $queue.Dequeue()
$j = Start-Job -name $cn -ScriptBlock {param($x); Start-Sleep -Seconds 10;"output - " + $x} -ArgumentList $cn
Register-ObjectEvent -InputObject $j -EventName StateChanged -Action {RunJobFromQueue; Unregister-Event $eventsubscriber.SourceIdentifier; Remove-Job $eventsubscriber.SourceIdentifier } | Out-Null
}
}
$maxConcurrentJobs = 2
$jobInput = "test1", "test2", "test3", "test4", "test5", "test6"
$queue = [System.Collections.Queue]::Synchronized( (New-Object System.Collections.Queue) )
foreach($item in $jobInput) {$queue.Enqueue($item)}
for( $i = 0; $i -lt $maxConcurrentJobs; $i++){RunJobFromQueue}
In my opinion: it fails in script because:
you start script, define function and variables in script scope
script exits once it defines 2 initial jobs
job complete, registered action runs, but your function is not visible to it (defined in script scope) so it fails.
Two options to solve/ work around it:
define RunJobFromQueue function in global scope (function Global:RunJobFromQueue, $global:queue)
dot-source a script (. .\YourScript.ps1)
This works fine interactively, because in that case you define function/ variable in global scope, so -Action can find them just fine.

How can you set a time limit for a PowerShell script to run for?

I want to set a time limit on a PowerShell (v2) script so it forcibly exits after that time limit has expired.
I see in PHP they have commands like set_time_limit and max_execution_time where you can limit how long the script and even a function can execute for.
With my script, a do/while loop that is looking at the time isn't appropriate as I am calling an external code library that can just hang for a long time.
I want to limit a block of code and only allow it to run for x seconds, after which I will terminate that code block and return a response to the user that the script timed out.
I have looked at background jobs but they operate in a different thread so won't have kill rights over the parent thread.
Has anyone dealt with this or have a solution?
Thanks!
Something like this should work too...
$job = Start-Job -Name "Job1" -ScriptBlock {Do {"Something"} Until ($False)}
Start-Sleep -s 10
Stop-Job $job
Here's my solution, inspired by this blog post. It will finish running when all has been executed, or time runs out (whichever happens first).
I place the stuff I want to execute during a limited time in a function:
function WhatIWannaDo($param1, $param2)
{
# Do something... that maybe takes some time?
Write-Output "Look at my nice params : $param1, $param2"
}
I have another funtion that will keep tabs on a timer and if everything has finished executing:
function Limit-JobWithTime($Job, $TimeInSeconds, $RetryInterval=5)
{
try
{
$timer = [Diagnostics.Stopwatch]::StartNew()
while (($timer.Elapsed.TotalSeconds -lt $TimeInSeconds) -and ('Running' -eq $job.JobStateInfo.State)) {
$totalSecs = [math]::Round($timer.Elapsed.TotalSeconds,0)
$tsString = $("{0:hh}:{0:mm}:{0:ss}" -f [timespan]::fromseconds($totalSecs))
Write-Progress "Still waiting for action $($Job.Name) to complete after [$tsString] ..."
Start-Sleep -Seconds ([math]::Min($RetryInterval, [System.Int32]($TimeInSeconds-$totalSecs)))
}
$timer.Stop()
$totalSecs = [math]::Round($timer.Elapsed.TotalSeconds,0)
$tsString = $("{0:hh}:{0:mm}:{0:ss}" -f [timespan]::fromseconds($totalSecs))
if ($timer.Elapsed.TotalSeconds -gt $TimeInSeconds -and ('Running' -eq $job.JobStateInfo.State)) {
Stop-Job $job
Write-Verbose "Action $($Job.Name) did not complete before timeout period of $tsString."
} else {
if('Failed' -eq $job.JobStateInfo.State){
$err = $job.ChildJobs[0].Error
$reason = $job.ChildJobs[0].JobStateInfo.Reason.Message
Write-Error "Job $($Job.Name) failed after with the following Error and Reason: $err, $reason"
}
else{
Write-Verbose "Action $($Job.Name) completed before timeout period. job ran: $tsString."
}
}
}
catch
{
Write-Error $_.Exception.Message
}
}
... and then finally I start my function WhatIWannaDo as a background job and pass it on to the Limit-JobWithTime (including example of how to get output from the Job):
#... maybe some stuff before?
$job = Start-Job -Name PrettyName -Scriptblock ${function:WhatIWannaDo} -argumentlist #("1st param", "2nd param")
Limit-JobWithTime $job -TimeInSeconds 60
Write-Verbose "Output from $($Job.Name): "
$output = (Receive-Job -Keep -Job $job)
$output | %{Write-Verbose "> $_"}
#... maybe some stuff after?
I know this is an old post, but I have used this in my scripts.
I am not sure if its the correct use of it, but the System.Timers.Timer that George put up gave me an idea and it seems to be working for me.
I use it for servers that sometimes hang on a WMI query, the timeout stops it getting stuck.
Instead of write-host I then output the message to a log file so I can see which servers are broken and fix them if needed.
I also don't use a guid I use the servers hostname.
I hope this makes sense and helps you.
$MyScript = {
Get-WmiObject -ComputerName MyComputer -Class win32_operatingsystem
}
$JobGUID = [system.Guid]::NewGuid()
$elapsedEventHandler = {
param ([System.Object]$sender, [System.Timers.ElapsedEventArgs]$e)
($sender -as [System.Timers.Timer]).Stop()
Unregister-Event -SourceIdentifier $JobGUID
Write-Host "Job $JobGUID removed by force as it exceeded timeout!"
Get-Job -Name $JobGUID | Remove-Job -Force
}
$timer = New-Object System.Timers.Timer -ArgumentList 3000 #just change the timeout here
Register-ObjectEvent -InputObject $timer -EventName Elapsed -Action $elapsedEventHandler -SourceIdentifier $JobGUID
$timer.Start()
Start-Job -ScriptBlock $MyScript -Name $JobGUID
Here is an example of using a Timer. I haven't tried it personally, but I think it should work:
function Main
{
# do main logic here
}
function Stop-Script
{
Write-Host "Called Stop-Script."
[System.Management.Automation.Runspaces.Runspace]::DefaultRunspace.CloseAsync()
}
$elapsedEventHandler = {
param ([System.Object]$sender, [System.Timers.ElapsedEventArgs]$e)
Write-Host "Event handler invoked."
($sender -as [System.Timers.Timer]).Stop()
Unregister-Event -SourceIdentifier Timer.Elapsed
Stop-Script
}
$timer = New-Object System.Timers.Timer -ArgumentList 2000 # setup the timer to fire the elapsed event after 2 seconds
Register-ObjectEvent -InputObject $timer -EventName Elapsed -SourceIdentifier Timer.Elapsed -Action $elapsedEventHandler
$timer.Start()
Main
How about something like this:
## SET YOUR TIME LIMIT
## IN THIS EXAMPLE 1 MINUTE, BUT YOU CAN ALSO USE HOURS/DAYS
# $TimeSpan = New-TimeSpan -Days 1 -Hours 2 -Minutes 30
$TimeSpan = New-TimeSpan -Minutes 1
$EndTime = (Get-Date).AddMinutes($TimeSpan.TotalMinutes).ToString("HH:mm")
## START TIMED LOOP
cls
do
{
## START YOUR SCRIPT
Write-Warning "Test-Job 1...2...3..."
Start-Sleep 3
Write-Warning "End Time = $EndTime`n"
}
until ($EndTime -eq (Get-Date -Format HH:mm))
## TIME REACHED AND END SCRIPT
Write-Host "End Time reached!" -ForegroundColor Green
When using hours or days as a timer, make sure you adjust the $TimeSpan.TotalMinutes
and the HH:mm format, since this does not facilitate the use of days in the example.
I came up with this script.
Start-Transcript to log all actions and save them to a file.
Store the current process ID value in the variable $p then write it to screen.
Assign the current date to the $startTime variable.
Afterwards I assign it again and add the extra time to the current date to the var $expiration.
The updateTime function return what time there is left before the application closes. And writes it to console.
Start looping and kill process if the timer exceeds the expiration time.
That's it.
Code:
Start-Transcript C:\Transcriptlog-Cleanup.txt #write log to this location
$p = Get-Process -Id $pid | select -Expand id # -Expand selcts the string from the object id out of the current proces.
Write-Host $p
$startTime = (Get-Date) # set start time
$startTime
$expiration = (Get-Date).AddSeconds(20) #program expires at this time
# you could change the expiration time by changing (Get-Date).AddSeconds(20) to (Get-Date).AddMinutes(10)or to hours whatever you like
#-----------------
#Timer update function setup
function UpdateTime
{
$LeftMinutes = ($expiration) - (Get-Date) | Select -Expand minutes # sets minutes left to left time
$LeftSeconds = ($expiration) - (Get-Date) | Select -Expand seconds # sets seconds left to left time
#Write time to console
Write-Host "------------------------------------------------------------------"
Write-Host "Timer started at : " $startTime
Write-Host "Current time : " (Get-Date)
Write-Host "Timer ends at : " $expiration
Write-Host "Time on expire timer : "$LeftMinutes "Minutes" $LeftSeconds "Seconds"
Write-Host "------------------------------------------------------------------"
}
#-----------------
do{ #start loop
Write-Host "Working"#start doing other script stuff
Start-Sleep -Milliseconds 5000 #add delay to reduce spam and processing power
UpdateTime #call upadate function to print time
}
until ($p.HasExited -or (Get-Date) -gt $expiration) #check exit time
Write-Host "done"
Stop-Transcript
if (-not $p.HasExited) { Stop-Process -ID $p -PassThru } # kill process after time expires