Today I needed again to check some long running process and after it finished I wanted to see a notification that tells me that I can continue (I was waiting for msbuild to rebuild my source files).
As for notification I use Growl hacked by Jaykul. It works very well. If you haven't tried it, give it a chance.

The second part is the regular checking. In this case it could look something like this:

[1]: while((get-process msbuild)) { sleep -sec 10; }; growl 'msbuild ended'

Or the condition could be in other situations (get-date) -lt '12:05' or whatever.

You probably see the repeating pattern: while some condition (scriptblock) is true, wait for some time and then check the condition again. And – what's frustrating – it blocks my console!

That's why I created a simple solution that uses timer and built in way Register-ObjectEvent that can handle the events.
The features are:

  • Doesn't block my console!
  • Much more simplified usage, no repeated code.
  • You can check more independent events simultaneously.
  • Checks the condition every X milliseconds.
  • You can specify action that is executed during each check and after the condition (while cycle) finishes.

Code and examples

param(
    [Parameter(Mandatory=$false)][scriptblock]$processAction={},
    [Parameter(Mandatory=$false)][scriptblock]$endAction={},
    [Parameter(Mandatory=$true)][scriptblock]$endCondition,
    [Parameter(Mandatory=$false)][PsObject]$data,
    [Parameter(Mandatory=$true)][int]$interval
)

# unregister previous stale subscribers
if (!$global:__timers__tounregister) { $global:__timers__tounregister = @() }
$global:__timers__tounregister | 
    % { Get-EventSubscriber $_ -ErrorAction SilentlyContinue } | 
    Unregister-Event
$global:__timers__tounregister = @()

# just in case that the timers list would be helpful ;)
if (!$global:__timers__) {
    $global:__timers__ = @()
}
Write-Debug "Interval: $interval"
if ($interval -lt 10) { throw "Interval $interval too short" }

$timer = New-Object Timers.Timer
$timer.Interval = $interval

# note - redirections to $null are needed to avoid errors
# concretely - Get-Item : The WriteObject and WriteError methods 
#   cannot be called after the pipeline has been closed.
# however, it doesn't help sometimes :|
$action = {
    if (!(&$event.MessageData.End)) {
        $event.MessageData.Timer.Stop()
        & $event.MessageData.EndAction $event.MessageData.UserData > $null
        $global:__timers__tounregister += $event.MessageData.SourceIdentifier
        $global:__timers__ = @($__timers__ | ? {$_ -ne $event.MessageData.Timer })
    }
    else {
        & $event.MessageData.ProcessAction > $null
    }
}

$identifier = [Guid]::NewGuid()
$messData = New-Object PsObject -Property @{
    Timer=$timer; 
    SourceIdentifier=$identifier;
    UserData=$data;
    End=$endCondition;
    ProcessAction=$processAction;
    EndAction=$endAction;}
    
$job = Register-ObjectEvent `
    -InputObject $timer `
    -EventName Elapsed `
    -Action $action `
    -SourceIdentifier $identifier `
    -MessageData $messData
$timer.Start()

$global:__timers__ += $timer
$job
[1]: ps:\Start-Timer.ps1 -interval 1000 `
  -endCondition {get-process *msbuild* -ea 0 }`
  -endAction {[system.windows.forms.messagebox]::Show('msbuild ended') }

So I solved my problem! You can imagine many more examples from the simple ones (as following) to some advanced stuff like checking emails ;)

[2]: $t = (get-date).AddMinutes(1)
[3]: ps:\Start-Timer.ps1 -interval 1000 `
  -endCondition {(date) -lt $t}.GetNewClosure() `
  -endAction {[system.windows.forms.messagebox]::Show('game over') }
[4]: ps:\Start-Timer.ps1 -interval 300 `
  -endCondition {(get-item C:\temp\numbers.txt).Length -lt 1kb} `
  -endAction { [system.windows.forms.messagebox]::Show('Too big log file') }
  # now during this 'cycle' you will see a message box
[5]: 1..500 | % { sleep -mil 10; $_|Add-Content C:\temp\numbers.txt }
[4]: $t = (get-date).AddMinutes(1)
[5]: ps:\Start-Timer.ps1 -interval 300 `
  -endCondition {(get-date) -lt $t}.GetNewClosure() `
  -processAction { 
		if ((gi C:\temp\numbers.txt).Length -gt 1kb) { 
			Remove-Item C:\temp\numbers.txt } 
	}
[6]: 1..2000 | % { sleep -mil 20; $_|Add-content C:\temp\numbers.txt }

Simpler call

I added a function check to my profile to simplify how I call it:

function check {
    param(
        [Parameter(Mandatory=$true,Position=1)][PsObject]$condition,
        [Parameter(Mandatory=$true,Position=2)][string]$message,
        [Parameter(Mandatory=$false)][int]$interval=1000
    )
    $date = $condition -as [datetime]
    if ($date -ne $null) {
        $condition = { (get-date) -lt $date }.GetNewClosure()
    } elseif (!($condition -is [scriptblock])) {
        throw "$condition can not be converted to date and is not scriptblock"
    }
    $params = @{
        Interval=$interval;
        EndCondition=$condition;
        EndAction={growl $message }.GetNewClosure()
    }
    & (join-path $powershellDir tools\Start-Timer.ps1) @params
}

I use my function growl to notify me, but you can replace it by the message box as in previous examples.

[1]: check { Get-Process chrome -ea 0 } 'chrome closed'
[2]: check 6:00 'wake up!'

Traps

Closed pipeline

Nothing comes without costs. From time to time I have a problem with the last example – it fails with this message: Get-Item : The WriteObject and WriteError methods cannot be called after the pipeline has been closed. I don't know how to workaround it.

GetNewClosure() not working

Furthermore the code is more complicated than it could be. It is because I couldn't use GetNewClosure() because of this problem: New closure on scriptblock. If I could I would just skip passing the scriptblocks and timer in MessageData and avoid the hashtable.

Console hangs

Another complication comes with unregistering the subscriber. I reported a bug on connect: Posh console hangs when unregistering event.

Basically, the problem is that I tried to call something like this:

Register-ObjectEvent $timer Elapsed -SourceIdentifier III`
 -action { Get-EventSubscriber III | Unregister-Event }

If PowerShell tries to execute the action's body and you press Enter, the PowerShell console stops responding.

I guess it is caused by method PSLocalEventManager.DrainPendingActions(PSEventSubscriber subscriber) that is called during Unregister-Event. Inside the method there is a part of code like this:

while (this.IsExecutingEventAction)
{
    Thread.Sleep(100);
}

What does it mean? If some action is being executed then sleep for 100 milliseconds. But.. wait. This code is called from an action. So, in fact it checks that the action is running and calls Sleep. So, the action can not be finished, because … it is sleeping. And so on …

Performance

Try to run 3 timers, each with interval 100 milliseconds and specify only condition like 'time is less than...'. Look at your processor. It is quite busy, much more than I think it should be (at least on my home laptop). I guess that pure C# console application would run without any notice (but i didn't try it).

Download

This task was much more difficult than I thought it would be. Maybe I do the things wrong, maybe there are some bugs in PowerShell core, maybe I don't know some principles.
Anyway, I hope you learned something. At least you could remember method named GetNewClosure().

Meta: 2010-03-12, Pepa