I have a service that performs some complex automation against 3rd party software (via .NET UIAutomation). The service consists of a host which spins up another STA Thread to run the automation on while the host waits via a ManualResetEvent. This was working great until the decision was made to use this in a user-facing environment. At that point, I created an AutomationMessenger class to drive status updates out to the UI/WPF app. This worked well. Then at some point, I needed to get and validate user input mid-automation and drive it back down to the automation thread- this is where the wheels have fallen off the bus. I added a call back action to the AutomationMessenger, and all seemed well. I ended up shuffling things around a bit, and the user input was needed at a different point in the automation. Ultimately, the methods in the automation classes began to get called twice- So I knew I had some threading issues at play.
Not surprisingly, these issues are not present in testing, or when the service is run without raising user notifications.
The Threads are created as expected and sending messages from the automation up to the UI layer to be invoked by Application.Current.Dispatcher works to update the UI, but when a callback action within the automation code/thread is invoked on the UI thread, control is never returned to that thread. I can see it happening in the Threads window in VS.
This is not surprising to me, I just can't figure out how to fix it.
It's taken me some effort to boil the complexity of this code down to a Minimal, Reproducible Example, but I have managed, and it illustrates the issue.
GitHub - AutomationThreadingIssue
I've spent considerable time reading about SynchronizationContexts, and I thought that it was going to be as simple as passing the context from the automation thread up to the UI to Send/Post to it, but learned that worker threads don't automatically get a context.
Simply calling Join() on the automation thread seems to kill the automation thread, or merge it into the UI thread.
Starting a new Task (Task.Run(() => Callback)) from the UI thread is the best solution I have at the moment to keep the UI/automation threads separate, but that's creating multiple hits back into some of the subsequent automation methods which respond to windows/dialogs popping up in the 3rd party software (via Automation.AddAutomationEventHandler).
After searching for all possibilities of "cross-thread messaging", the consensus is a BlockingCollection/ConcurrentQueue, or Channels. The project is limited to net45, which rules out the channels (minimum requirement is 4.6). And I couldn't get a blocking collection implementation working without blocking execution in the automation thread.
I see a lot of solutions for invoking a delegate/action back on a UI thread from a worker thread, but nothing useful for invoking a method from the UI thread back on a worker thread.
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
Thread.CurrentThread.Name = "WPF Thread";
AutomationMessenger.Received = AutomationMessenger_Received;
}
private void AutomationMessenger_Received(object sender, AutomationMessengerEventArgs e)
{
switch (e.Name)
{
case "Status":
Application.Current.Dispatcher.Invoke(() =>
{
Status_TextBlock.Text = e.Message;
});
break;
case "UserInput":
Application.Current.Dispatcher.Invoke(() =>
{
AutomationInput_Grid.IsEnabled = true;
Input_Button.Tag = e.CallbackAction;
Status_TextBlock.Text = e.Message;
});
break;
}
}
private void Run_Button_Click(object sender, RoutedEventArgs e)
{
Run_Button.IsEnabled = false;
var backgroundAutomationTask = new Task(() =>
{
var worker = new AutomationBackgroundWorker();
worker.Begin();
});
backgroundAutomationTask.Start();
}
private void Input_Button_Click(object sender, RoutedEventArgs e)
{
if (Input_Button.Tag != null && Input_Button.Tag is Action<string> callbackAction)
{
callbackAction.Invoke(Input_TextBox.Text);
}
}
}
public class AutomationBackgroundWorker
{
private ManualResetEvent manualResetEvent;
private Thread automationThread;
private SynchronizationContext backgroundWorkerContext;
public AutomationBackgroundWorker()
{
Thread.CurrentThread.Name = "Automation Background Worker";
backgroundWorkerContext = SynchronizationContext.Current;
automationThread = new Thread(this.Automation);
automationThread.Name = "Automation Thread";
automationThread.SetApartmentState(ApartmentState.STA);
manualResetEvent = new ManualResetEvent(false);
}
public void Begin()
{
automationThread.Start();
manualResetEvent.WaitOne();
}
public void Complete()
{
manualResetEvent.Set();
manualResetEvent.Close();
}
public void Automation()
{
var automationSyncContext = SynchronizationContext.Current;
AutomationMessenger.Send("Status", "Initializing", null);
// Initialize Automation Elements, etc..
Task.Delay(1000).Wait();
AutomationMessenger.Send("Status", "Initialized", null);
// Get user input
AutomationMessenger.Send("UserInput", "Please Enter Your Name", UserInputCallback);
// Wait for their response
}
// As you would expect, this method is being called from the WPF Thread / UI Thread
// How can I synchronize it back to the Automation Thread
private void UserInputCallback(string userInput)
{
//if (Thread.CurrentThread != automationThread)
//{
// automationThread.Join();
//}
// Doing this blocks and kills the Automation Thread
if (string.IsNullOrWhiteSpace(userInput)) return;
AutomationMessenger.Send("Status", $"Thanks {userInput}", null);
Complete();
}
}
public static class AutomationMessenger
{
public static event AutomationMessengerEventHandler Received;
public static void Send(string name, string message, Action<string> callbackAction) => Received?.Invoke(null, new AutomationMessengerEventArgs(name, message, callbackAction));
}
public delegate void AutomationMessengerEventHandler(object sender, AutomationMessengerEventArgs e);
public class AutomationMessengerEventArgs : EventArgs
{
public string Name { get; set; }
public string Message { get; set; }
public Action<string> CallbackAction { get; set; }
public AutomationMessengerEventArgs(string name, string message, Action<string> callbackAction) : base()
{
Name = name;
Message = message;
CallbackAction = callbackAction;
}
}
CodePudding user response:
After spending most of the day trying to implement a custom SynchronizationContext, and being fairly successful in that, I stumbled into a way of making a BlockingCollection<T> work for my needs.
TL;DR for the posts which suggest using a BlockingCollection<T> to ensure Actions/delegates/methods are run on one thread, here is a boiled down (you need to add your own error handling) example:
PoorMansSynchronizier aka AutomataionActionQueue.
public class PoorMansSynchronizier
{
private readonly BlockingCollection<Action> _queue;
private readonly Thread _thread;
public PoorMansSynchronizier(Action<Thread> options)
{
_queue = new BlockingCollection<Action>();
_thread = new Thread(() => Execute());
options?.Invoke(_thread);
if (!_thread.IsAlive) _thread.Start();
}
public void Invoke(Action action) => _queue.Add(action);
public void Complete() => _queue.CompleteAdding();
private void Execute()
{
foreach (var action in _queue.GetConsumingEnumerable())
{
action.Invoke();
}
}
}
Updated Background Worker Service
public class AutomationBackgroundWorker
{
private ManualResetEvent manualResetEvent;
private PoorMansSynchronizier poorMansSynchronizier;
public AutomationBackgroundWorker()
{
Thread.CurrentThread.Name = "Automation Background Worker";
poorMansSynchronizier = new PoorMansSynchronizier(thread =>
{
thread.Name = "Automation Thread";
thread.SetApartmentState(ApartmentState.STA);
});
manualResetEvent = new ManualResetEvent(false);
}
public void Begin()
{
poorMansSynchronizier.Invoke(() => Automation());
manualResetEvent.WaitOne();
}
public void Complete()
{
manualResetEvent.Set();
manualResetEvent.Close();
poorMansSynchronizier.Complete();
}
public void Automation()
{
AutomationMessenger.Send("Status", "Initializing", null);
// Initialize Automation Elements, etc..
Task.Delay(1000).Wait();
AutomationMessenger.Send("Status", "Initialized", null);
// Get user input
AutomationMessenger.Send("UserInput", "Please Enter Your Name", UserInputCallback);
// Wait for their response
}
private void UserInputCallback(string userInput)
{
poorMansSynchronizier.Invoke(() =>
{
// Do something with the response
if (string.IsNullOrWhiteSpace(userInput)) return;
AutomationMessenger.Send("Status", $"Thanks {userInput}", null);
Complete();
});
}
}
The key here is, that the poor man's synchronizer has its own Thread, and its purpose is to execute each item on the queue.
I was able to do away with the background worker having to hold a reference to the old automationThread in the original question code, and now any action I need to run on the STA automation thread, I just pass to the queue.
This has no effect on the original implementation of the non-user-facing automation code- As far as it knows, it's still running on an STA thread named Automation Thread, and it's as happy as it was before the user-facing requirements were implemented- No doom and gloom or crippling technical debt that only a giant refactor of the automation code could solve @Enigmativity.
