3

EDIT: The sample code is far off from the original goal, but there's a legitimate answer to the question that I actually posed, so I've edited the question and title.

I am writing a multithread PowerShell script with a WinForms GUI and would like to update the GUI from one of the other threads in the script. I'm having no luck doing this at all. In fact, I can't even get BeginInvoke to work.

Here's a very stripped down example. This is sample code, not production, so it's not pretty, but it's replicating the issue:

$script:SynchronizedUIHash = [Hashtable]::Synchronized(@{});

[reflection.assembly]::LoadWithPartialName( "System.Windows.Forms")
$form= New-Object Windows.Forms.Form
$label = New-Object Windows.Forms.Label
$label.Text = 'original'
$script:SynchronizedUIHash.label = $label

$form.Controls.add($label)
$form.show()
[System.Windows.Forms.Application]::DoEvents()

Read-Host

$label.BeginInvoke(
    [Action[string]] {
        param($Message)
        [Console]::Beep(500,300)
        $l = $SynchronizedUIHash.label
        $l.Text = $Message
        [System.Windows.Forms.Application]::DoEvents()
    },
    'changed'
)

Read-Host

$form.close()

I don't even hear the console beep, so it's as if the BeginInvoke code isn't being executed at all.

If I throw in a [System.Windows.Forms.Application]::DoEvents() after the Read-Host then I see the change so it seems to be a timing issue, but I don't want to go into a spin loop or something silly like that - I want to fire and forget the BeginInvoke and have the results visible as appropriate. I see other answers like the one to PowerShell: Job Event Action with Form not executed but that needs to be in a check, sleep, check loop.

There's got to be a simpler way that I'm missing... what is it?

What is the right solution here?

EDIT: Keith's comment made me realize I missed a key point going from production to the stripped sample code - launching a job is indeed multi-process, but the sample code isn't, so the sample doesn't reflect real life, and I'll have to rethink the whole thing. That all said - the sample code doesn't use jobs, and it doesn't work as I expect either, so I'd like to understand why, even if that doesn't help me in this specific case.

Community
  • 1
  • 1
MikeBaz - MSFT
  • 2,938
  • 4
  • 28
  • 57
  • just to make something clear: if you take the above code, save as a .ps1, and run it, you can see the issue. My thought about the DoEvents is that there are queued-up window messages to change the text but it's not clear to me how to get WinForms to see those. – MikeBaz - MSFT Jan 18 '13 at 15:28
  • Why don't you do Application.Run(form); instead of form.show? – Simon Mourier Jan 18 '13 at 15:59
  • That's a reasonable question - in this case it was ease of throwing together the sample to demonstrate the issue. The production code is MUCH more complicated and does do an `[System.Windows.Forms.Application]::Run` call. That call is modal so I would need the sample to have a button, wire up a button event, etc. or write up a form Load event. I don't believe that impacts the underlying issue. – MikeBaz - MSFT Jan 18 '13 at 16:07
  • I'm not familiar with powershell funky syntax :-) would a C# one be ok? – Simon Mourier Jan 18 '13 at 16:55
  • I'm not sure I could reproduce the problem in C# to be honest - I've done cross-thread in C# WinForms and it's always worked. I think something specific to PS-WinForms interaction is happening. That said I will spend a few minutes to see if that's true - it should be easy enough to create C# code, it's just a question of if it is also broken in the same way. – MikeBaz - MSFT Jan 18 '13 at 16:57
  • I don't think it's specific to powershell, I think it's specific to your code. I will post my answer anyway. It's not complicated. – Simon Mourier Jan 18 '13 at 16:58
  • @SimonMourier The most common way of showing UI from PowerShell is to use ShowDialog(). But neither method will return until the form is closed. – Keith Hill Jan 18 '13 at 18:10
  • Correcting an earlier comment - production code is using ShowDialog() not Run() – MikeBaz - MSFT Jan 18 '13 at 18:43
  • @KeithHill - yes, that's really a threading issue. You need one thread for the windows message loop and another one for console char handling. In this sample, there is only one. See my updated answer to this. – Simon Mourier Jan 20 '13 at 10:43

2 Answers2

2

You can't really have a Console and a Windows run on the same thread. I suggest you create another thread to host the window, like this (It's a C# console app, but it should be easy to translate):

class Program
{
    static void Main(string[] args)
    {
        Form form = new Form();
        Label label = new Label();
        label.Text = "original";
        form.Controls.Add(label);

        // run this form on its own thread, so it can work properly
        Thread t = new Thread((state) => Application.Run((Form)state));
        t.Start(form);

        Console.ReadLine();

        // note you *could* do label.Text = "other" here, but BeginInvoke is always recommended
        label.BeginInvoke((Action)delegate() { label.Text = "other"; });

        Console.ReadLine();
        Application.Exit();
    }
}

EDIT: It took me a while to cook a Powershell version, but here it is. The trick is: you just can't create a standard .NET thread from the Powershell environement. Period. As soon as you try to do it, you will crash Powershell. The underlying error that causes the crash is this:

System.Management.Automation.PSInvalidOperationException: There is no Runspace available to run scripts in this thread. You can provide one in the DefaultRunspace property of the System.Management.Automation.Runspaces.Runspace type

And, you can't really try to set this DefaultRunspace variable from another thread because it's marked threadstatic (one copy per thread)! Now, if you google for multi-threading Powershell, you will find many articles about Powershell Jobs. But these allow multi-tasking, but it's not really multi-threading, it's more heavyweight.

So... turns out the solution is really to use Powershell features, that is: Powershell runspace. Here it is:

[reflection.assembly]::LoadWithPartialName( "System.Windows.Forms")
$form = New-Object System.Windows.Forms.Form
$label = New-Object System.Windows.Forms.Label
$label.Text = 'original'
$form.Controls.add($label)

$ps = [powershell]::create()
$ps.AddScript(
     {
     [System.Windows.Forms.Application]::Run($form)
     })
$ps.Runspace.SessionStateProxy.SetVariable("form", $form)
$ps.BeginInvoke()

Read-Host

$label.BeginInvoke(
    [Action[string]] {
        param($Message)
        $label.Text = $Message
    },
    'changed'
)

Read-Host

[System.Windows.Forms.Application]::Exit()
$ps.Dispose()
Simon Mourier
  • 132,049
  • 21
  • 248
  • 298
  • I'm not 100% on how to translate the closure to PowerShell but I'm working on it - I'll post one way or the other. – MikeBaz - MSFT Jan 18 '13 at 17:18
  • ok so I managed to crash PS repeatedly trying to manage my own threads (which is PS is a very weird thing to do). So I don't think this is as presented a usable solution :( I'll beat on it a little more but I guess I'm still looking for a PS solution proper. (Thank you for the answer, though!) – MikeBaz - MSFT Jan 18 '13 at 18:02
  • Yes, I also tried Powershell and faced a few crashes. I don't know if it's possible to create a thread in Powershell. start-job doesn't really seem to start a real thread. – Simon Mourier Jan 19 '13 at 12:08
  • @MikeBaz - I have added a new 100% Powershell version I think it needs Powershell 2.0 or higher. – Simon Mourier Jan 20 '13 at 10:36
  • ok so this answers the question but not the title (which is wrong at this point because of the otherwise mentioned wrong example code) - so I'm going to accept this at this point, change the title, and work through a better example and result. – MikeBaz - MSFT Jan 21 '13 at 15:16
0

Invoke()-ing works across threads. Your PowerShell script will get it's own process. At that level, you're looking at inter-process communication solutions, like remoting or message queueing, instead of inter-thread communication tools like Invoke().

From Powershell, the easiest of those might be your own little "message queue", whereby the powershell writes something into a text file that the winforms app is watching and knows what to do when the data appears.

Joel Coehoorn
  • 399,467
  • 113
  • 570
  • 794
  • I'm sorry I'm not clear - where is the extra process coming from in my sample code? – MikeBaz - MSFT Jan 18 '13 at 15:25
  • I misunderstood: I was thinking the winform was a separate program that called a powershell script, not a powershell script creating a form from scratch. – Joel Coehoorn Jan 18 '13 at 15:27
  • @MikeBaz - What do you mean by "multijob" then? PowerShell jobs are run in a separate process. However PowerShell provides mechanisms like Receive-Job to get output from these job processes. – Keith Hill Jan 18 '13 at 18:13
  • Keith - you have probably hit upon an important big picture point which will make me need to rethink this. Receive-Job is a poor choice because it requires polling of some sort. That said, I'd still like to understand why even the sample code doesn't work, which does not have jobs at all. – MikeBaz - MSFT Jan 18 '13 at 18:23
  • ahhhh could it be this: http://stackoverflow.com/questions/2192558/why-does-a-form-displayed-by-powershell-sometimes-not-show-up - lack of message pump = failure to execute? – MikeBaz - MSFT Jan 18 '13 at 18:40