Avoiding COM exceptions caused by incorrect Thread Apartments

The Automation Interface COM object must be run in STA. STA stands for Single-Threaded Apartment. The reason lies in the COM threading model. STA-registered objects expect all calls to be serialized via the same thread that created the object. Code that runs in MTA threads – for example, BackgroundWorker, ThreadPool, or Task.Run – violates this rule and causes RPC_E_SERVERCALL_RETRYLATER exceptions because the COM server cannot directly handle the call from the foreign apartment. This is not a defect in the Automation Interface, but rather a fundamental property of STA-COM objects. The solution is to consistently provide a dedicated STA thread.

The problem

// WRONG — BackgroundWorker uses thread-pool threads (MTA)
var worker = new BackgroundWorker();
worker.DoWork += (sender, e) =>
{
    // RPC_E_SERVERCALL_RETRYLATER: COM object is STA, thread is MTA
    sysManager.LookupTreeItem("TIPC^PLC1");  // Error!
};
worker.RunWorkerAsync();

The same applies to Task.Run(() => ...) and ThreadPool.QueueUserWorkItem(...), since they all use MTA pools.

Solution: Explicitly creating an STA thread

public static class AsyncFactory
{
    private static StaTaskScheduler _sta = new StaTaskScheduler(numberOfThreads: 1);

    public static async Task StartNew(Action action, bool configureAwait = false)
    {
        try
        {
            await Task.Factory.StartNew(action, CancellationToken.None, TaskCreationOptions.None, _sta).ConfigureAwait(configureAwait);
        }
        catch (Exception ex)
        {
            throw ex;
        }
    }

    public static async Task<TResult> StartNew<TResult>(Func<TResult> function, bool configureAwait = false)
    {
        try 
        {
            return await Task.Factory.StartNew(function, CancellationToken.None, TaskCreationOptions.None, _sta).ConfigureAwait(configureAwait);
        }
        catch (Exception ex) 
        {
            throw ex;
        }
    }

}

This AsyncFactory can now be used to start tasks that run concurrently and do not block the main thread. It is crucial that all COM objects be created and used within the actions queued via the factory. This ensures that they run on the scheduler's STA thread and are addressed from there. The following example shows a typical application flow in which tasks are passed to the AsyncFactory one after another: create a DTE instance, create a solution with a TwinCAT project, query the ITcSysManager, execute a longer-running COM operation, and finally clean up.

await RunTwinCATAsync();

async Task RunTwinCATAsync()
{
    Console.WriteLine($"Main thread: {Environment.CurrentManagedThreadId}");

    // Step 1 - Create a new DTE instance on the STA thread.
    // The MessageFilter is registered to handle RPC_E_CALL_REJECTED errors.
    DTE dte = await AsyncFactory.StartNew(() =>
    {
        MessageFilter.Register();
        Console.WriteLine($"STA thread: {Environment.CurrentManagedThreadId}");

        Type t = Type.GetTypeFromProgID("TcXaeShell.DTE.17.0");
        DTE instance = (DTE)Activator.CreateInstance(t);
        instance.SuppressUI = false;
        instance.MainWindow.Visible = true;
        return instance;
    });

    Console.WriteLine("DTE instance created. Creating solution...");

    // Step 2 - Create a new solution and add a TwinCAT project from a template.
    await AsyncFactory.StartNew(() =>
    {
        string solutionDir = @"C:\tmp\Test\AsyncSolution";
        string solutionName = "AsyncSolution";

        Solution sol = dte.Solution;
        sol.Create(solutionDir, solutionName);

        string tcTemplate = @"C:\Program Files (x86)\Beckhoff\TwinCAT\3.1\Components\Base\PrjTemplate\TwinCAT Project.tsproj";
        sol.AddFromTemplate(tcTemplate,
            Path.Combine(solutionDir, "TwinCATProject1"),
            "TwinCATProject1");

        sol.SaveAs(Path.Combine(solutionDir, $"{solutionName}.sln"));
        Console.WriteLine("Solution saved.");
    });

    // Step 3 - Access the TwinCAT System Manager and query the PLC tree item.
    string plcPath = await AsyncFactory.StartNew(() =>
    {
        ITcSysManager sysManager = dte.Solution.Projects.Item(1).Object as ITcSysManager;
        ITcSmTreeItem plc = sysManager.LookupTreeItem("TIPC");
        return plc.PathName;
    });

    Console.WriteLine($"PLC path: {plcPath}");

    // Step 4 - Run a long-running COM operation while doing other work concurrently.
    var comTask = AsyncFactory.StartNew(() =>
    {
        // Simulate a time-consuming operation (e.g. code generation)
        System.Threading.Thread.Sleep(2000);
        return dte.Solution.FullName;
    });

    Console.WriteLine("Performing other work while COM operation is in progress...");
    await Task.Delay(500);
    Console.WriteLine("Other work completed.");

    string fullName = await comTask;
    Console.WriteLine($"Solution: {fullName}");

    // Step 5 - Clean up: close the DTE instance and revoke the message filter.
    await AsyncFactory.StartNew(() =>
    {
        dte.Quit();
        MessageFilter.Revoke();
    });

    Console.WriteLine("Done.");
}

Background

TwinCAT COM objects such as ITcSysManager, ITcSmTreeItem, etc., are STA-registered. All calls must come from the same STA thread in which the object was created. BackgroundWorker and Task.Run use thread pool threads, which are MTA by default.

Avoiding COM exceptions caused by incorrect Thread Apartments 1:

The Message Filter (IMessageFilter) is registered on the STA thread and handles temporary “Please wait” responses from the COM server. For asynchronous scenarios, an explicit STA thread is more stable than a message filter. In PowerShell scripts, -Sta resolves the same issue when the PS process starts.