Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

PowerShell asynchronous timer events not working outside of testing console

I have a PowerShell script that uses an Asynchronous Timer Event (background process) to measure how long a certain condition has been occurring before taking appropriate action. This is working perfectly fine when I run the script inside PowerGUI but when I run the script using dot-sourcing or run it via a batch file the Timer Event actions are not firing.

Here is a code snippet.

$timer = New-Object System.Timers.Timer
$timer.Interval = 10000  
$timer.AutoReset = $true  
$timeout = 0

$action = { 
    "timeout: $timeout" | Add-Content $loglocation
    <more stuff here>
    $timer.stop()
}  
$start = Register-ObjectEvent -InputObject $timer -SourceIdentifier TimerElapsed -EventName Elapsed -Action $action

$timer.start()

while(1)
{
    <do some testing here>
}

So when it works, I will see the "timeout: XX" output every 10 seconds written to the log. But this is only happening when run inside the editor. When I run it via batch file nothing happens (although I can confirm the while loop is processing fine).

So my question is why is my experience different when I'm running the script inside PowerGUI versus via command line? My thought is there might be an issue with scoping or parallel threads but I'm not exactly sure what the issue is. Also I am not running these events inside any functions or loops.

like image 669
avirex Avatar asked Jun 03 '13 16:06

avirex


2 Answers

When calling the script file, the $action script block is executed using the scope of the caller (parent scope), not the script file's scope (child scope). Therefore, variables defined within the script file are not available within the $action script block, unless they are defined to use the global scope or dot-sourced (which will make them available in the global scope). See this wonderful article for more information.

Assume the below code is contained within a file named test.ps1.

$timer = New-Object System.Timers.Timer
$timer.Interval = 10000  
$timer.AutoReset = $false

$timeout = 100
$location = 'SomeLocation'
$sourceIdentifier = 'SomeIdentifier'

$action = { 
Write-Host "Timer Event Elapsed. Timeout: $timeout, Location: $location, SourceIdentifier: $sourceIdentifier"
$timer.stop()
Unregister-Event $sourceIdentifier
}  

$start = Register-ObjectEvent -InputObject $timer -SourceIdentifier $sourceIdentifier -EventName Elapsed -Action $action

$timer.start()

while(1)
{
 Write-Host "Looping..."
 Start-Sleep -s 5
}

When calling from the powershell console, when the $action script block is executed, the variables it uses will have no values.

./test.ps1

Timer Event Elapsed. Timeout: , Location: , SourceIdentifier:

If you define the variables used in the $action script block before you call the script, the values will be available when the action executes:

$timeout = 5; $location = "SomeLocation"; $sourceIdentifier = "SomeSI"
./test.ps1

Timer Event Elapsed. Timeout: 5, Location: SomeLocation, SourceIdentifier: SomeSI

If you dot-source the script, the variables defined within the script will become available in the current scope, so when the action executes, the values will be available:

. ./test.ps1

Timer Event Elapsed. Timeout: 100, Location: SomeLocation, SourceIdentifier: SomeIdentifier

If the variables would have been declared in the global scope in the script file:

$global:timeout = 100
$global:location = 'SomeLocation'
$global:sourceIdentifier = 'SomeIdentifier'

Then when the $action script block executes in the parent scope, the values will be available:

./test.ps1

Timer Event Elapsed. Timeout: 100, Location: SomeLocation, SourceIdentifier: SomeIdentifier
like image 182
dugas Avatar answered Nov 08 '22 07:11

dugas


Like dugas' answer, but if you don't want to clutter up your PowerShell instance with extra variables or do any dot-sourcing, you can put it in a function. This also has the benefit of letting you use named parameters and makes it more modular if you want to re-use it in the future.

function Start-Timer
{
    param($timeout = 5, $location = "SomeLocation", $sourceIdentifier = "SomeSI")
    
    $timer = [System.Timers.Timer]::new()
    $timer.Interval = $timeout
    $timer.AutoReset = $False

    $action = 
    { 
        $myArgs = $event.MessageData
        $timeout = $myArgs.timeout
        $location = $myArgs.location
        $sourceIdentifier = $myArgs.sourceIdentifier
        $timer = $myArgs.timer

        Write-Host "Timer Event Elapsed. Timeout: $timeout, Location: $location, SourceIdentifier: $sourceIdentifier"
        $timer.Stop()
        Unregister-Event $sourceIdentifier
    }

    # You have to pass the data this way
    $passThru = 
    @{
        timeout = $timeout;
        location = $location;
        sourceIdentifier = $sourceIdentifier;        
        timer = $timer;
    }
    Register-ObjectEvent -InputObject $timer -EventName Elapsed -SourceIdentifier Tick -Action $action -MessageData $passThru | Out-Null

    $timer.Start()
}

Then you can call it with named parameters:

Start-Timer -location "NewLocation"

A disadvantage to purely using this approach is that if the Handler uses a large number of variables from the containing scope, the code will get messy.

like image 34
General Grievance Avatar answered Nov 08 '22 05:11

General Grievance