COM-Ausnahmen durch falsche Thread-Apartments vermeiden

Das Automation Interface-COM-Objekt muss im STA ausgeführt werden. STA steht für Single-Threaded-Apartment. Der Grund liegt im COM-Threading-Modell. STA-registrierte Objekte erwarten, dass alle Aufrufe über denselben Thread serialisiert werden, der das Objekt erzeugt hat. Code, der in MTA-Threads läuft, zum Beispiel BackgroundWorker, ThreadPool oder Task.Run, bricht diese Zusage und verursacht RPC_E_SERVERCALL_RETRYLATER-Ausnahmen, weil der COM-Server den Aufruf aus dem fremden Apartment nicht direkt bedienen kann. Dies ist kein Defekt des Automation Interface, sondern eine grundlegende Eigenschaft von STA-COM-Objekten. Die Lösung besteht darin, konsequent einen eigenen STA-Thread bereitzustellen.

Das 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();

Gleiches gilt für Task.Run(() => ...) und ThreadPool.QueueUserWorkItem(...), da alle MTA-Pool-Threads nutzen.

Lösung: Explizit STA-Thread erzeugen

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;
        }
    }

}

Diese AsyncFactory kann nun genutzt werden, um Tasks zu starten, die nebenläufig laufen und den Main-Thread nicht blockieren. Entscheidend ist, dass alle COM-Objekte innerhalb der über die Factory eingereihten Aktionen erzeugt und verwendet werden. So ist garantiert, dass sie auf dem STA-Thread des Schedulers leben und auch von dort aus angesprochen werden. Das folgende Beispiel zeigt einen klassischen Anwendungsablauf, bei dem nacheinander Aufgaben an die AsyncFactory übergeben werden: DTE-Instanz erzeugen, Solution mit TwinCAT-Projekt anlegen, ITcSysManager abfragen, eine länger laufende COM-Operation ausführen und am Schluss aufräumen.

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.");
}

Hintergrund

TwinCAT-COM-Objekte wie ITcSysManager, ITcSmTreeItem usw. sind STA-registriert. Alle Aufrufe müssen vom selben STA-Thread kommen, in dem das Objekt erzeugt wurde. BackgroundWorker und Task.Run verwenden Thread-Pool-Threads, die standardmäßig MTA sind.

COM-Ausnahmen durch falsche Thread-Apartments vermeiden 1:

Der Message Filter (IMessageFilter) wird auf dem STA-Thread registriert und behandelt temporäre "Bitte warten"-Antworten vom COM-Server. Für asynchrone Szenarien ist ein expliziter STA-Thread stabiler als ein Message Filter. In PowerShell-Skripten löst -Sta beim Start des PS-Prozesses dasselbe Problem.