I'm writing a C# application that will involve collecting data from Exchange Online using the PowerShell V2 module. After clients perform an admin consent, I will make PowerShell connections to their environments using a multi-threaded C# app running on a Windows virtual machine. I'm using Net 5.0 and PowerShell 7.x. I need to use multiple threads because collecting data from a single tenant can be a lengthy process.
The problem is that while the app runs fine, if I try to run the application for two tenants at the same time using multiple threads, there is collision. The module does not appear to be thread-safe.
I've built a service that gets injected via .Net DI as a transient. This service creates a HostedRunspace class that performs state management for PowerShell.
public class HostedRunspace : IDisposable
{
private Runspace runspace;
public void Initialize(string[] modulesToLoad = null)
{
InitialSessionState defaultSessionState = InitialSessionState.CreateDefault();
defaultSessionState.ExecutionPolicy = Microsoft.PowerShell.ExecutionPolicy.RemoteSigned;
if (modulesToLoad != null)
{
foreach (string moduleName in modulesToLoad)
{
defaultSessionState.ImportPSModule(moduleName);
}
}
runspace = RunspaceFactory.CreateRunspace(defaultSessionState);
runspace.ThreadOptions = PSThreadOptions.UseNewThread;
runspace.ApartmentState = ApartmentState.STA;
runspace.Open();
}
public async Task<List<PSObject>> RunScript(string scriptContents, Dictionary<string, object> scriptParameters = null)
{
if (runspace == null)
{
throw new ApplicationException("Runspace must be initialized before calling RunScript().");
}
PSDataCollection<PSObject> pipelineObjects;
using (System.Management.Automation.PowerShell ps = System.Management.Automation.PowerShell.Create(runspace))
{
ps.AddScript(scriptContents);
if (scriptParameters != null)
{
ps.AddParameters(scriptParameters);
}
ps.Streams.Error.DataAdded += Error_DataAdded;
ps.Streams.Warning.DataAdded += Warning_DataAdded;
ps.Streams.Information.DataAdded += Information_DataAdded;
// execute the script and await the result.
pipelineObjects = await ps.InvokeAsync().ConfigureAwait(false);
// print the resulting pipeline objects to the console.
Console.WriteLine("----- Pipeline Output below this point -----");
foreach (PSObject item in pipelineObjects)
{
Console.WriteLine(item.BaseObject.ToString());
}
}
List<PSObject> psObjects = new List<PSObject>();
foreach (PSObject pipelineObject in pipelineObjects)
{
psObjects.Add(pipelineObject);
}
return psObjects;
}
When it becomes time to collect a tenant's PowerShell data, a new thread is created like so:
IOnlineDataTaskRunner taskRunner = serviceProvider.GetRequiredService<IOnlineDataTaskRunner>();
Thread thread = new Thread(() => taskRunner.RunAsync(dataTask));
thread.Start();
Here I'm getting a transient version of my PowerShell service, which will itself new up a HostedRunspace. I create a new thread, provide it some configuration and start the thread.
When the thread runs, I first must connect to Exchange Online which I do using a certificate.
string command = $"Connect-ExchangeOnline -CertificateThumbprint \"{Thumbprint}\" -AppId \"{ClientId}\" -ShowBanner:$false -Organization {tenant}";
await runspace.RunScript(command);
Then, after this, I perform a variety of other data retrieval tasks using the PowerShell Module, including retrieving mailbox information, storage size, etc. These are also executed via
await runspace.RunScript(command);
As stated above, if I run one thread at a time, there is no issue. But if I connect thread 1 to tenant A and thread 2 to tenant B, the initial Connect-ExchangeOnline will take place with no issues.
But then if you retrieve mailbox information, for example, both threads will pull data for whichever tenant connected last. This indicates that there may be a threading issue with the module or perhaps with my implementation.