F# cross-thread UI exception in WinForms App

775 views Asked by At

I have a problem with developing a simple application in F#, which just reads the length of the requested HTML page.

Seems to be that such an error would be similar for VB.NET/C# language too, when you develop the UI application.

enter image description here

But I'm rather new to F# and don't really imagine hot to fix such issue exactly in F#.

Source code in F#:

http://pastebin.com/e6WM0Sjw

open System
open System.Net
open Microsoft.FSharp.Control.WebExtensions
open System.Windows.Forms

let form = new Form()
let text = new Label()
let button = new Button()

let urlList = [ "Microsoft.com", "http://www.microsoft.com/" 
                "MSDN", "http://msdn.microsoft.com/" 
                "Bing", "http://www.bing.com"
              ]

let fetchAsync(name, url:string) =
    async { 
        try 
            let uri = new System.Uri(url)
            let webClient = new WebClient()
            let! html = webClient.AsyncDownloadString(uri)
            text.Text <- String.Format("Read %d characters for %s", html.Length, name)
        with
            | ex -> printfn "%s" (ex.Message);
    }

let runAll() =
    urlList
    |> Seq.map fetchAsync
    |> Async.Parallel 
    |> Async.RunSynchronously
    |> ignore

form.Width  <- 400
form.Height <- 300
form.Visible <- true
form.Text <- "Test download tool"

text.Width <- 200
text.Height <- 50
text.Top <- 0
text.Left <- 0
form.Controls.Add(text)

button.Text <- "click me"
button.Top <- text.Height
button.Left <- 0
button.Click |> Event.add(fun sender -> runAll() |> ignore)
form.Controls.Add(button)

[<STAThread>]
do Application.Run(form)

Best Regards,

Thanks!

3

There are 3 answers

7
Gene Belitski On BEST ANSWER

You must switch thread context to UI thread from Async ThreadPool prior to updating text.Text property. See MSDN link for the F# Async-specific explanation.

After modifying your snippet by capturing UI context with

let uiContext = System.Threading.SynchronizationContext()

placed right after your let form = new Form() statement and changing fetchAsync definition to

let fetchAsync(name, url:string) =
    async { 
        try 
            let uri = new System.Uri(url)
            let webClient = new WebClient()
            let! html = webClient.AsyncDownloadString(uri)
            do! Async.SwitchToContext(uiContext)
            text.Text <- text.Text + String.Format("Read {0} characters for {1}\n", html.Length, name)
        with
            | ex -> printfn "%s" (ex.Message);
    }

it works without any problems.

UPDATE: After discussing the debugger idiosyncrasy with a colleague, who emphasized the need of cleanly manipulating UI context, the following modification is agnostic now to the manner of run:

open System
open System.Net
open Microsoft.FSharp.Control.WebExtensions
open System.Windows.Forms
open System.Threading

let form = new Form()
let text = new Label()
let button = new Button()

let urlList = [ "Microsoft.com", "http://www.microsoft.com/"
                "MSDN", "http://msdn.microsoft.com/"
                "Bing", "http://www.bing.com"
              ]

let fetchAsync(name, url:string, ctx) =
    async {
        try
            let uri = new System.Uri(url)
            let webClient = new WebClient()
            let! html = webClient.AsyncDownloadString(uri)
            do! Async.SwitchToContext ctx
            text.Text <- text.Text + sprintf "Read %d characters for %s\n" html.Length name
        with
            | ex -> printfn "%s" (ex.Message);
    }

let runAll() =
    let ctx = SynchronizationContext.Current
    text.Text <- String.Format("{0}\n", System.DateTime.Now)
    urlList
    |> Seq.map (fun(site, url) -> fetchAsync(site, url, ctx))
    |> Async.Parallel
    |> Async.Ignore
    |> Async.Start

form.Width  <- 400
form.Height <- 300
form.Visible <- true
form.Text <- "Test download tool"

text.Width <- 200
text.Height <- 100
text.Top <- 0
text.Left <- 0
form.Controls.Add(text)

button.Text <- "click me"
button.Top <- text.Height
button.Left <- 0
button.Click |> Event.add(fun sender -> runAll() |> ignore)
form.Controls.Add(button)

[<STAThread>]
do Application.Run(form) 
1
Tomas Petricek On

As an alternative to using the Invoke operation (or context switching) explicitly, you can also start the computation using Async.StartImmediate.

This primitive starts the asynchronous workflow on a current thread (synchronization context) and then it ensures that all continuations are called on the same synchronization context - so it essentially handles Invoke automatically for you.

To do that, you do not need to change anything in fetchAsync. You just need to change how the computation is started in runAll:

let runAll() =
    urlList
    |> Seq.map fetchAsync
    |> Async.Parallel 
    |> Async.Ignore
    |> Async.StartImmediate

Just like before, this composes all of them in parallel, then it ignores the result to get a computation of type Async<unit> and then it starts it on a current thread. This is one of the nice features in F# async :-)

1
Ray On

I had the same problem in that the Async.SwitchToContext did not switch to the Main Gui thread. It was in fact switching to some other thread.

In the end I found that the problem was how I got the uiContext in the first place. Using the following worked:

let uiContext = System.Threading.SynchronizationContext.Current

But it didnt work with:

let uiContext = System.Threading.SynchronizationContext()