fsprojects / Avalonia.FuncUI

Develop cross-plattform GUI Applications using F# and Avalonia!
https://funcui.avaloniaui.net/
MIT License
955 stars 74 forks source link

Avalonia.Threading.Dispatcher: Call from invalid thread when using Cmd.OfAsync #375

Closed Palsskv closed 5 months ago

Palsskv commented 10 months ago

There seems to be an issue with async workloads that are not scheduled on the UI thread.

module Program =
    [<STAThread>]  // <-- used for drag & drop.
    [<EntryPoint>]
    let main (args: string[]) =
        AppBuilder
            .Configure<App>()
            .UsePlatformDetect()
            .UseSkia()
            .StartWithClassicDesktopLifetime(args)

...

let update (msg: AppMsg) (appModel: AppModel) =
    match msg with
    | ParseFile uri ->
        let processing =
            fun _ ->
                async {
                    let! e = ThisWillThrowAsync()
                    ...
                }

        { appModel with
            State = InputFileSelected uri}
        , (Elmish.Cmd.OfAsync.attempt processing () (fun ex -> FailParse ex.Message))

The async workload is executed on the ThreadPool image

All follow-up view & update code is then run from the TP Thread which ends up throwing:

Unhandled exception. System.InvalidOperationException: Call from invalid thread
   at Avalonia.Threading.Dispatcher.<VerifyAccess>g__ThrowVerifyAccess|16_0()
   at Avalonia.Threading.Dispatcher.VerifyAccess()
   at Avalonia.AvaloniaObject.VerifyAccess()
   at Avalonia.AvaloniaObject.GetValue[T](StyledProperty`1 property)
   at Avalonia.Controls.ContentControl.get_Content()
   at Avalonia.FuncUI.VirtualDom.VirtualDom.updateRoot(ContentControl host, FSharpOption`1 last, FSharpOption`1 next)
   at Avalonia.FuncUI.Hosts.HostWindow.update(FSharpOption`1 nextViewElement)
   at Avalonia.FuncUI.Hosts.HostWindow.Avalonia.FuncUI.Hosts.IViewHost.Update(FSharpOption`1 next)
   at Avalonia.FuncUI.Elmish.Program.withHost@14.Invoke(model state, FSharpFunc`2 dispatch)
   at Elmish.ProgramModule.processMsgs@178.Invoke(Unit unitVar0)
   at Elmish.ProgramModule.dispatch@170.Invoke(msg msg)
   at Elmish.Cmd.OfAsyncWith.bind@115-12.Invoke(FSharpChoice`2 r)
   at Microsoft.FSharp.Control.AsyncPrimitives.CallThenInvokeNoHijackCheck[a,b](AsyncActivation`1 ctxt, b result1, FSharpFunc`2 userCode) in D:\a\_work\1\s\src\FSharp.Core\async.fs:line 528
   at Microsoft.FSharp.Control.Trampoline.Execute(FSharpFunc`2 firstAction) in D:\a\_work\1\s\src\FSharp.Core\async.fs:line 112
--- End of stack trace from previous location ---
   at Microsoft.FSharp.Control.AsyncPrimitives.Start@1174-1.Invoke(ExceptionDispatchInfo edi) in D:\a\_work\1\s\src\FSharp.Core\async.fs:line 1174
   at Microsoft.FSharp.Control.Trampoline.Execute(FSharpFunc`2 firstAction) in D:\a\_work\1\s\src\FSharp.Core\async.fs:line 112
   at <StartupCode$FSharp-Core>.$Async.clo@193-15.Invoke(Object o) in D:\a\_work\1\s\src\FSharp.Core\async.fs:line 195
   at System.Threading.ThreadPoolWorkQueue.Dispatch()
   at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart()

Running with Elmish.Cmd.OfAsyncImmediate.attempt fixes this, but puts the async code on the main thread.

JordanMarr commented 10 months ago

Try Program.runWithAvaloniaSyncDispatch

Palsskv commented 10 months ago

Thanks, @JordanMarr. It works now.

Maybe it makes sense to set up an exception handler somewhere in the FuncUI stack, as a temporary measure? Make it reraise with more detailed instructions on setting up dispatcher synchronization.

I've just started using this library, so my understanding of its design is practically nil :). From a user perspective this looks like a bug though, because the ofError function only returns the message without calling into dispatch.

Palsskv commented 10 months ago

Not sure if this is connected, but the happy path messages don't get processed.

Full code:

let update (msg: AppMsg) (appModel: AppModel) =
    match msg with
    | ParseFile uri ->
        let processing =
            fun _ ->
                async {
                    let! rows = Parser.AsyncLoad(uri.LocalPath) // the parser throws for invalid input

                    if (rows.Rows |> Seq.isEmpty |> not) then
                        return ItemsParsed(rows.Rows |> Seq.toArray)
                    else
                        return FailParse "No rows found in file"
                }

        { appModel with
            State = InputFileSelected uri}
        , (Elmish.Cmd.OfAsyncImmediate.attempt processing () (fun ex -> FailParse ex.Message)

type AppMsg =
    | ParseFile of Uri
    | FailParse of string
    | ItemsParsed of Parser.Row array
    | FileDragDropMsg of FileDragDrop.FileDragDropMsg

I have tracing enabled with Program.withTrace (fun msg model subs -> printfn $"msg: {msg} {model} {subs}") and the messages don't get logged or otherwise processed.

JordanMarr commented 10 months ago

Ushering async data back to the main UI thread has been a part of UI development for a long time. But I get your point.

I think Program.runWithAvaloniaSyncDispatch () should be used by default as there really is no drawback. And maybe we should consider making that call obsolete and instead introducing: Program.runFuncUI ()

JordanMarr commented 10 months ago

Not sure if this is connected, but the happy path messages don't get processed.

You should be able to just use Cmd.OfAsync instead of Cmd.OfAsyncImmediate.

Stylistically, it would look better if you:

Palsskv commented 10 months ago

For anyone reading this, the happy path issue was unrelated. either is what I should have used instead of attempt. attempt accepts functions with returns, but does not dispatch the successful, non-exception result.

If I get more time to spend on UI work, I could look into patching attempt to accept 'a -> unit functions only and submit a PR. @JordanMarr let me know if this sits well with the intended architecture.

JordanMarr commented 10 months ago

Glad you got it working. 😎 I don’t personally think there is a need to change any of the built-in Elmish handlers in this library. It would be better to propose any changes in the Elmish repository itself.