Gracefully disposing threads when the Editor stops

I’m trying to use a NamedPipe IPC to read data passed to my Windows Standalone build as to simulate a “deeplink”. In order to do so, I start a simple thread that waits until the Pipe Server sends data to the Pipe Client, and when it does, I invoke an event on the main thread to pass the data over (it only consists of a simple string).

The thread uses a while loop that runs until a CancellationToken requests cancellation, which I flag inside an OnDestroy.

I’m having trouble gracefully terminating the thread if the Editor is stopped. If I stop it, the next time I try to hit Play, it gets stuck “Reloading Domain” and forces me to termiante the program through the Task Manager. I did try waiting it out once, but gave up after 40 minutes had passed.
I haven’t tested this on an actual build yet, but I assume it’s not gonna be working either.

I’m guessing this is connected to my handling of the thread, I haven’t really used them until now. Maybe I’m not terminating it correctly? Can I somehow check if the thread is running after Unity stops?

I personally don’t really like how big chunks of code look on here, so I also created a public gist with my code.

namespace DeepLinks
{
	[DisallowMultipleComponent]
	public class DeepLinkListener : MonoBehaviour
	{
		// Singleton
		public static DeepLinkListener Instance { get; private set; }
		protected virtual void Awake()
		{
			if (Instance != null)
			{
				Destroy(gameObject);
				return;
			}
			Instance = this;
		}

		public static event Action<string> OnDeepLinkReceived = delegate { };
		private const string PIPE_NAME = "pipeName";
		private PipeListener pipeListener;

		
		private void Start()
		{
			pipeListener = new PipeListener(PIPE_NAME);
			pipeListener.OnDeepLinkReceived += _arg => OnDeepLinkReceived?.Invoke((string)_arg);
			try
			{
				pipeListener.StartListening();
			}
			catch (Exception e)
			{
				Debug.LogError("Disposing deeplink listener. " + e.Message);
				pipeListener?.Dispose();
			}
		}
		
		private void OnDestroy()
		{
			print("disposing thread");
			pipeListener?.Dispose();
		}
	}

	public class PipeListener : IDisposable
	{
		public event Action<object> OnDeepLinkReceived = delegate { };
		public event Action<Exception> OnThreadAborted = delegate { };
		
		private const int READ_INTERVAL_MS = 500;
		
		private static volatile SynchronizationContext mainThreadContext;
		private volatile CancellationTokenSource cancellationTokenSource = new();
		private Thread listenerThread;
		private NamedPipeServerStream pipe;
		private StreamReader reader;
		private string pipeName;
		private bool isRunning;
		
		
		public PipeListener(string _pipeName)
		{
			if (string.IsNullOrEmpty(_pipeName))
				throw new ArgumentException("_pipeName is null.");

			pipeName = _pipeName;
			mainThreadContext = SynchronizationContext.Current;
			
			// Restart listening after a deeplink is received
			OnDeepLinkReceived += StartListening;
			// Also restart listening if an error occurred
			OnThreadAborted += StartListening;
		}

		private void StartListening(object _) => StartListening();
		public async void StartListening()
		{
			// Clean up any previous threads
			if (listenerThread != null)
			{
				Dispose();
				await UniTask.WaitUntil(() => listenerThread == null || listenerThread.ThreadState 
					is ThreadState.Stopped 
					or ThreadState.Aborted 
					or ThreadState.Unstarted);
			}
			
			listenerThread = new Thread(ListenToPipe)
			{
				IsBackground = true,
				Name = "Deep Link Listener",
				Priority = System.Threading.ThreadPriority.Lowest
			};
			
			listenerThread.Start();
		}
	
		private void ListenToPipe()
		{
			cancellationTokenSource = new CancellationTokenSource();
			isRunning = true;

			while (isRunning && !cancellationTokenSource.Token.IsCancellationRequested)
			{
				using (pipe = new NamedPipeServerStream(pipeName, PipeDirection.In))
				{
					pipe.WaitForConnection();
					reader = new StreamReader(pipe);
				
					try
					{
						string data = reader.ReadLine();
						if (!string.IsNullOrEmpty(data))
						{
							SendToMainThread(OnDeepLinkReceived, data);
						}
					}
					catch (Exception e) // If anything breaks, just clean the thread and warn the caller
					{
						Debug.LogError("Pipe Thread encountered Exception. Calling OnThreadAborted, which should restart the thread");
						SendToMainThread(OnThreadAborted, e);
						break;
					}
				}
				
				//Thread.Sleep(READ_INTERVAL_MS); 
			}
			
			isRunning = false;
			reader?.Close();
			pipe?.Close();
			Debug.LogError("Thread has finished");
		}
		

		public static void SendToMainThread<T>(Action<T> _action, T _parameter)
		{
			mainThreadContext.Post(_ => _action(_parameter), null);
		}
		
		public void Dispose()
		{
			isRunning = false;
			cancellationTokenSource?.Cancel();
			cancellationTokenSource?.Dispose();
			Debug.LogError("Disposed thread, waiting for it to finish.");
			//listenerThread?.Abort();
		}
	}
}

I’d be grateful if anyone can help. Thanks.

It seems I have found the problem! I didn’t notice pipe.WaitForConnection() was halting the thread while it waited for the pipe to send data, meaning my while conditions were never met and the thread never got the message that it needed to end when the Editor stopped.

Switching to await pipe.WaitForConnectionAsync() appears to have fixed the main issue, the only thing left was getting rid of the while loop and refactor a couple things around it. I now also make sure to close the pipe when disposing the object for good measure.

If anyone’s reading this in the future, remember the NamedPipeServerStream needs to be instantiated with PipeOptions.Asynchronous for WaitForConnectionAsync() to work.