Call an async method (within a child form), from a thread from the main form

241 views Asked by At

I really want to understand and solve this problem I've been struggling with for a few days. I read hundreds of threads and I can't get it to work.

I have a primary form, and on a secondary form a WebView2 control. The purpose is to control the WebView2 from the primary form. They are configured as follows:

My goal is to use the primary form to load some HTML code into the WebView2 from the secondary form, and then take a screenshot of that page. The HTML loading part is working so I will focus next on the screenshot problem. I use this method for taking the screenshot. Which in itself it's working fine when called directly, but it fails when I call it from a thread or a background worker. And yes, this behavior is expected due to the cross-thread calls, I know. Please bear with me!

Ideally, I thought that it should work something like this (oversimplified code):

private void PrimaryForm_Load(...)
{
    _secondaryForm = new SecondaryForm();
    _secondaryForm.Show();
}

private void Button_Click(...)
{
    Thread thread = new Thread(HeavyWork);
    thread.Start();
}

private void HeavyWork()
{
    // this method will run a few hundred times
    Task<string> task = CaptureScreenshotAsync();
    string str = task.Result;
    // further process the str...
}

private async Task<string> CaptureScreenshotAsync()
{
    // it will throw a cross-thread exception
    return await _secondaryForm.webView.CoreWebView2.CallDevToolsProtocolMethodAsync("Page.captureScreenshot", "{}");
}

As you can see here I've tried invoking it in a few ways with no luck. Also, making it synchronous. Tried this, this and that - among others. I can't get it to work as it should.

I've also tried to call the HeavyWork method directly like this:

private void Button_Click(...)
{
    HeavyWork();
}

But no luck, I either get a cross-thread exception, or a freeze. Like this or this.

The only "hack" I accidentally found on my own is to "sleep" a little bit before getting the task result like this:

private void HeavyWork()
{
    Task<string> task = CaptureScreenshotAsync();
    Sleep(250);
    string str = task.Result;
}

And this is the Sleep method:

private void Sleep(int interval)
{
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.Start();
    while (stopwatch.ElapsedMilliseconds < interval)
    {
        Application.DoEvents();
    }
    stopwatch.Stop();
}

But this is just a wonky "solution" - sometimes it doesn't work (I assume that because it takes longer to take the screenshot than that wait time). I'm sure that there must be a proper way of handling it. I would greatly appreciate any help. Thanks!

3

There are 3 answers

0
Jey On

Try it like this:

public partial class Form1 : Form
{
    Form2 scnd;
    public Form1()
    {
        scnd = new Form2(this);
        scnd.Show();
        InitializeComponent();
    }


    public void worksFine(Image image)
    {

        pictureBox1.Invoke(() => pictureBox1.Image = image);
    }

    private void button1_Click(object sender, EventArgs e)
    {
        backgroundWorker1.RunWorkerAsync();
    }

    private void backgroundWorker1_DoWork(object sender, System.ComponentModel.DoWorkEventArgs e)
    {
        scnd.AnyFunction(sender, e);
    }
}
 public partial class Form2 : Form
 {
     Form1 form1;
     public Form2(Form1 form1)
     {
         this.form1 = form1;
         InitializeComponent();
         InitilizeWebView();
     }

     public async void InitilizeWebView ()
     {
         await webView21.EnsureCoreWebView2Async();
         webView21.CoreWebView2.Navigate("http://www.Google.com");
     }


     public void AnyFunction(object sender, EventArgs e)
     {
         webView21.Invoke(DoStuffwithTheWebView);
          
     }
     private async void DoStuffwithTheWebView ()
     {

         Image image = await TakeAScreenshot();
         form1.worksFine(image);
     }

     private async Task<Image> TakeAScreenshot ()
     { //your function works here too without any problem
         string r = await webView21.CoreWebView2.CallDevToolsProtocolMethodAsync("Page.captureScreenshot", "{}");
         JObject o = JObject.Parse(r);
         JToken data = o["data"];
         string data_str = data.ToString();
         byte[] imageBytes = Convert.FromBase64String(data_str);
         using (var ms = new MemoryStream(imageBytes, 0, imageBytes.Length))
         {
             return Image.FromStream(ms, true);
         }
     }
 }
0
Charlieface On

The issue is that you can't access UI elements from a different thread. So take the screenshot, then offload processing to the thread pool using Task.Run.

private async void Button_Click(...)
{
    await HeavyWorkAsync();
}

private async Task HeavyWorkAsync()
{
    // this method will run a few hundred times
    var str = await CaptureScreenshotAsync();
    await Task.Run(() => {
        // further process the str...
    });
}
0
IV. On

There are many, many ways to go about this but when I want to do this kind of thing in my own app I find that a decent way to go about it is to implement ICommand in the child window that is the screenshot/service provider.

public partial class SnapshotProviderForm : Form, ICommand
{
    public event EventHandler? CanExecuteChanged;
    public bool CanExecute(object? parameter) { return true; }
    public void Execute(object? parameter) 
    {
        try
        {
            Debug.Assert(
                !InvokeRequired, 
                "Usually on the UI thread at this point (because e.g. a button was clicked.");
            await Task.Run(() => {  /* Do something async on a background thread e.g. */ });
            await Task.Delay();     /* Do something async 'on' the UI thread          */  
        }
        finally
        {
            if(o is AsyncCommandContext awaitable) 
            {
                awaitable.Release();
            }
        }
    }
}

The ICommand.Execute(context) method is not going to be awaitable, but the context argument that gets passed into it can be.

Context passed as argument to ICommand.Execute


class AsyncCommandContext
{
    private SemaphoreSlim _busy { get; } = new SemaphoreSlim(0, 1);
    public TaskAwaiter GetAwaiter()
    {
        return _busy
        .WaitAsync()        // Do not use the Token here
        .GetAwaiter();
    }
    public ConfiguredTaskAwaitable ConfigureAwait(bool configureAwait)
    {
        return
            _busy
            .WaitAsync()    // Do not use the Token here, either.
            .ConfigureAwait(configureAwait);
    }
    public void Release()
    {
        // Make sure there's something to release.
        _busy.Wait(0);
        _busy.Release();
    }
}
enum TimerCommandMode { Toggle, Start, Stop, Restart }
class TimerCommandContext: AsyncCommandContext
{
    public TimerCommandMode TimerCommandMode { get; set; }
}

class ScreenshotCommandContext : AsyncCommandContext
{ 
    public bool OpenEditor { get; set; }
    public string? Path { get; internal set; }
}

You can browse the full example but basically the screenshot server will do what you ask it to do and release your context when it's done. One nuance is that it helps avoid the kind of async deadlocks that can cause even the most asynchronous of apps to hang. To prove this out, the screenshot provider will be this top-level borderless form:

SnapshotProviderForm

child window

The stand-alone behavior of this child form is to toggle a stopwatch when clicked, and when it is control-clicked to capture a single screenshot and display it in a new instance of MS Paint and not release the context until MS Paint is closed by the user. When the main form requests this action below, it means that if the user makes changes in Paint they will be reflected in the file that the main form subjects to the long-running processing.


So for the part of the question

Call an async method (within a child form)...

this will indeed call the async method, but it will call the async method through the interface on a call that is not awaited. Passing in an awaitable context provides the means for the client to wait for the work product to complete without blocking anything. Here's is what that could look like on the client (Main Form) side:

public partial class MainForm : Form
{
    public MainForm()
    {
        InitializeComponent();
        Disposed += (sender, e) => SnapshotProviderForm.Dispose();
        buttonSingle.Click += async (sender, e) =>
        {
            var context = new ScreenshotCommandContext{ OpenEditor = true };
            SnapshotProviderForm.Execute(context);  // ICommand does not block and is not async.
            await context;                          // The task is awaited by virtue of the context awaiter.
            await ProcessFile(context.Path);        // Now we have a lock on the context.
            context.Release();                      // Release context for any 'other' awaiters of this context.
        };
        checkBoxAuto.CheckedChanged += async (sender, e) =>
        {
            if(checkBoxAuto.Checked) 
            {
                flowLayoutPanel.Controls.Clear();
                while (checkBoxAuto.Checked)
                {
                    var context = new ScreenshotCommandContext { OpenEditor = false }; // Different
                    SnapshotProviderForm.Execute(context);
                    await context;
                    await ProcessFile(context.Path);
                    context.Release();
                    await Task.Delay(TimeSpan.FromSeconds(5));
                }
            }
        };
    }

and for the part of the question

from a thread from the main form

private async Task ProcessFile(string fullPath)
{
    await Task.Run(() =>
    {
        Bitmap scaled;
        using (var orig = Bitmap.FromFile(fullPath))
        {
            // Process long running task.
            for (int i = 1; i <= 100; i++)
            {
                BeginInvoke(() =>
                {
                    progressBar.SetProgress(i);
                });
                Thread.Sleep(10); // Block this non-ui thread.
            }
            scaled = new Bitmap(orig.Width / 4, orig.Height / 4);
            using (Graphics graphics = Graphics.FromImage(scaled))
            {
                graphics.DrawImage(orig, 0, 0, scaled.Width, scaled.Height);
                var pictureBox = new PictureBox
                {
                    Size = scaled.Size,
                    Image = scaled,
                };
                BeginInvoke(() =>
                {
                    flowLayoutPanel.Controls.Add(pictureBox); 
                    flowLayoutPanel.AutoScrollPosition = new Point(0, flowLayoutPanel.VerticalScroll.Maximum);
                });
            }
        }
    });
}

[Single] button test

This shows the Desktop window with two screen captures open in Paint after clicking the [Single] button on the main form twice. MainForm continues to be responsive (and the stopwatch continues to increment) while waiting for the user to finish the edits and close the Paint window and when that happens it will process the resulting file and add it to the FlowLayoutPanel on the main form.

pending edits in MS Paint


[Auto} Loop Test

Obviously, when the [Auto] loop is executing on the client side, we skip the part about opening Paint and waiting for user. Here we're just taking periodic screenshots and adding them to FlowLayoutPanel after processing with long-running-task denoted by ProgressBar`.

auto