Avalonia.FuncUI icon indicating copy to clipboard operation
Avalonia.FuncUI copied to clipboard

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

Open palsskv opened this issue 2 years ago • 7 comments

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 [email protected](model state, FSharpFunc`2 dispatch)
   at [email protected](Unit unitVar0)
   at [email protected](msg msg)
   at [email protected](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 [email protected](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>[email protected](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.

palsskv avatar Nov 23 '23 16:11 palsskv

Try Program.runWithAvaloniaSyncDispatch

JordanMarr avatar Nov 23 '23 17:11 JordanMarr

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 avatar Nov 23 '23 17:11 palsskv

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.

palsskv avatar Nov 23 '23 18:11 palsskv

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 avatar Nov 23 '23 18:11 JordanMarr

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:

  • open Elmish instead of Elmish.Cmd everywhere
  • Make FailParse take an Exception instead of a string, and then could you just have: , Cmd.OfAsync.attempt processing () FailParse

JordanMarr avatar Nov 23 '23 18:11 JordanMarr

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.

palsskv avatar Nov 24 '23 18:11 palsskv

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.

JordanMarr avatar Nov 24 '23 20:11 JordanMarr