0

We have a WPF dialog library, which exposes an async Task ShowAsync(...) method, library has to be used. Around it, we've built a MVVM-based singleton service, DialogService with our own async ShowAsync method, and view models call it when needed. The problem is, the library does not support showing more than one dialog at the time and we have to keep the dispatcher thread working, so if another operation requests a dialog before the user closes the first one, the library throws an exception, which then cascades into another dialog call, so on and so on.

So we need to implement some sort of queueing, in the sense that second task cannot even begin (be cold?) until the first task is completed. It all has to happen in dispatcher thread, but on a plus not the ShowAsync is always called from the dispatcher thread and we use ConfigureAwait(true) on calling library method. Some of the calls to dialog service have their own ContinueWith constructs, if that is important.

I've seen some solutions like SerialQueue et al, but they all deal with serializing tasks in general, without caring on what context and thread they run, we need a more WPFy solution where everything runs on dispatcher thread without making it unresponsive.

Any ideas would be welcome.

mmix
  • 6,057
  • 3
  • 39
  • 65
  • Possibly related: [Task sequencing and re-entracy](https://stackoverflow.com/questions/21424084/task-sequencing-and-re-entracy) – Theodor Zoulias Oct 11 '22 at 12:46
  • 1
    I would probably not recommend using a `ShowAsync`-approach. Background work should not show dialogs, it should return a failure to the UI-layer and let this deal with the failure. If there is periodic bakground work it might be better to show the failure in some other way than a dialog. – JonasH Oct 11 '22 at 13:00
  • @JonasH, its a business requirement, so I do not have much freedom. This is not used just for failures, informational dialogs are showing up during the simulation process, there is a log as well, but management insists on having specific information shown. – mmix Oct 11 '22 at 13:17
  • @JonasH the bakcground threads DO send the info to the main thread, the ShowAsync called from back threads ultimately goes through Dispatcher.InvokeAsync. There is never a call to ShowAsync outside dispatcher thread, this is not a multi-threaded issue, its the reentrancy during await type of issue. – mmix Oct 11 '22 at 13:21
  • I'm criticizing the singleton part, not anything regarding thread safety. If I start some background work I want any results to be reported either by the return value, or thru parameters I give the method, that way the background work is cleanly separated from the UI. If the background work does things with the UI thru some singleton it will become more difficult to reason about the program state, and things like unit testing will likely also be impacted. – JonasH Oct 11 '22 at 13:41
  • 1
    You should use a different mechanism for information. A listbox or toast. Informational display should not be blocking anything. – Andy Oct 11 '22 at 13:58
  • @JonasH, the cingleton nature is dictated by the dialog library, which only supports one host. The host is not blocking. – mmix Oct 11 '22 at 14:16
  • @Andy, there is a log list with details, which is more verbose. The point exactly is that the dialogs do not block anything, the dialog host itself, although visually modal, is NOT blocking. The work carries on. – mmix Oct 11 '22 at 14:16
  • It sounds like your problem is with the dialog library. Why is it so hard to define a window in xaml that you need a separate library that doesn't do what you want? – BurnsBA Oct 11 '22 at 14:48
  • @BurnsBA dialog library is part of the materials library and management wants to use it. I constantly keep encountering such advices, not everything is under our control, I am not a beginner, if I could change the library I would. Option B was to rewrite the dialog part of the library and emulate it, but no point in wasting time if a workaround can be made, no? – mmix Oct 19 '22 at 09:21

1 Answers1

0

I solved it with a semaphore with a single choke point, the dialogs will queue up waiting for the semaphore to release. This solution seems more in line with await/async philosophy:

internal class DialogService : IDialogService
{
    private readonly DispatcherSynchronizationContextAwaiter uiContextAwaiter;
    private readonly SemaphoreSlim dialogSemaphore = new(1);

    public DialogService(DispatcherSynchronizationContextAwaiter uiContextAwaiter)
    {
        this.uiContextAwaiter = uiContextAwaiter;
    }

    public async Task<DialogResult> ShowDialogAsync(string title, string message, DialogType dialogType = DialogType.Information, DialogButtons dialogButtons = DialogButtons.OK)
    {
        await dialogSemaphore.WaitAsync();
        try
        {
            await uiContextAwaiter;
            var result = await DialogHost.Show(new DialogViewModel {Title = title, Message = message, DialogType = dialogType, DialogButtons = dialogButtons});
            return (DialogResult?) result ?? DialogResult.OK;
        }
        finally
        {
            dialogSemaphore.Release();
        }
    }
}

DispatcherSynchronizationContextAwaiter is not fundamentally important for my problem, but it does allow the ShowDialogAsync to be called from any thread. It simply posts a continuation on the dispatcher thread. I took this code from Thomas Levesque's blog from 2015, and adjusted to my needs. This is the source if you need it:

internal class DispatcherSynchronizationContextAwaiter: INotifyCompletion
{
    private static readonly SendOrPostCallback postCallback = state => ((Action)state)?.Invoke();

    private readonly SynchronizationContext context;
    public DispatcherSynchronizationContextAwaiter(SynchronizationContext context)
    {
        this.context = context;
    }

    public bool IsCompleted => context == SynchronizationContext.Current;

    public void OnCompleted(Action continuation) => context.Post(postCallback, continuation);
    
    public void GetResult() { }

    // clone yourself on GetAwait
    public  DispatcherSynchronizationContextAwaiter GetAwaiter() => new(context);
}
mmix
  • 6,057
  • 3
  • 39
  • 65