feat(core): STA dispatcher + KOMPAS connection session (S3)

- KompasDispatcher: выделенный STA-поток + очередь задач, InvokeAsync<T>,
  inline-выполнение вложенных вызовов, корректный Dispose
- NativeMethods: attach к запущенному COM (CLSIDFromProgID + GetActiveObject,
  замена отсутствующего в .NET 8 Marshal.GetActiveObject)
- KompasSession: подключение через API5 (KompasObject) -> API7 (IApplication),
  attach/launch, версия/редакция, освобождение COM на STA-потоке
- tests: 5 unit-тестов диспетчера + 3 интеграционных теста подключения (зелёные)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 23:42:52 +03:00
parent 1be3f4d8f9
commit 0038790902
8 changed files with 445 additions and 10 deletions
@@ -0,0 +1,40 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace Kompas.Mcp.Core.Interop;
/// <summary>
/// Нативные функции для attach к уже запущенному COM-серверу.
/// В .NET 5+ <c>Marshal.GetActiveObject</c> отсутствует, поэтому используем
/// <c>CLSIDFromProgID</c> (ole32) + <c>GetActiveObject</c> (oleaut32) напрямую.
/// </summary>
[SupportedOSPlatform("windows")]
internal static class NativeMethods
{
[DllImport("ole32.dll", PreserveSig = false)]
private static extern void CLSIDFromProgID(
[MarshalAs(UnmanagedType.LPWStr)] string progId, out Guid clsid);
[DllImport("oleaut32.dll", PreserveSig = false)]
private static extern void GetActiveObject(
ref Guid rclsid, IntPtr pvReserved,
[MarshalAs(UnmanagedType.IUnknown)] out object ppunk);
/// <summary>
/// Подключиться к запущенному экземпляру COM-сервера по ProgID.
/// Возвращает <c>null</c>, если ProgID не зарегистрирован или экземпляр не запущен.
/// </summary>
public static object? GetActiveObject(string progId)
{
try
{
CLSIDFromProgID(progId, out Guid clsid);
GetActiveObject(ref clsid, IntPtr.Zero, out object obj);
return obj;
}
catch (COMException)
{
return null; // MK_E_UNAVAILABLE / CO_E_CLASSSTRING и т.п.
}
}
}
@@ -0,0 +1,20 @@
namespace Kompas.Mcp.Core;
/// <summary>Сведения об установленном подключении к КОМПАС.</summary>
public sealed record KompasConnectionInfo
{
/// <summary>ProgID, по которому удалось подключиться (например, <c>KOMPAS.Application.5</c>).</summary>
public required string ProgId { get; init; }
/// <summary>true — экземпляр КОМПАС запущен этим процессом; false — присоединились к работающему.</summary>
public required bool WasLaunched { get; init; }
/// <summary>Версия системы в формате Major.Minor.Release.</summary>
public required string Version { get; init; }
/// <summary>Номер сборки внутри версии.</summary>
public required int BuildNumber { get; init; }
/// <summary>Редакция (по ProgID): "Home/LT" для KOMPASLT.*, иначе "Full".</summary>
public required string Edition { get; init; }
}
+154
View File
@@ -0,0 +1,154 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Interop;
using Kompas.Mcp.Core.Threading;
using Kompas6API5;
using KompasAPI7;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Kompas.Mcp.Core;
/// <summary>
/// Подключение к КОМПАС и владение корневыми COM-объектами на время жизни сервера.
/// <para>
/// Подключается через API5 (<see cref="KompasObject"/>), так как он нужен для построения 3D
/// (ksPart) и снимков (ksDocument3D), а из него получает современный API7 (<see cref="IApplication"/>).
/// Все обращения к COM выполняются на STA-потоке <see cref="KompasDispatcher"/>.
/// </para>
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class KompasSession : IDisposable
{
// Порядок попыток. Нужен KompasObject (API5), поэтому .7 не используем как точку входа.
// Проверено на v24 Home: зарегистрирован KOMPAS.Application.5 (не LT).
private static readonly string[] ProgIds =
{
"KOMPAS.Application.5",
"KOMPASLT.Application.5",
};
private readonly KompasDispatcher _dispatcher;
private readonly ILogger<KompasSession> _log;
private KompasObject? _kompas; // API5 root
private IApplication? _app; // API7 application
private bool _launchedByUs;
public KompasSession(KompasDispatcher dispatcher, ILogger<KompasSession>? log = null)
{
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_log = log ?? NullLogger<KompasSession>.Instance;
}
public bool IsConnected => _app is not null;
/// <summary>Корневой объект API5. Доступен только на STA-потоке после подключения.</summary>
public KompasObject Kompas =>
_kompas ?? throw new InvalidOperationException("Нет подключения к КОМПАС. Сначала вызовите ConnectAsync.");
/// <summary>Приложение API7. Доступно только на STA-потоке после подключения.</summary>
public IApplication Application =>
_app ?? throw new InvalidOperationException("Нет подключения к КОМПАС. Сначала вызовите ConnectAsync.");
/// <summary>Подключиться к КОМПАС: attach к запущенному или launch нового экземпляра.</summary>
public Task<KompasConnectionInfo> ConnectAsync(bool visible = true, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() => ConnectCore(visible), ct);
private KompasConnectionInfo ConnectCore(bool visible)
{
if (_app is not null)
return BuildInfo(LastProgId);
// 1) attach к уже запущенному экземпляру
foreach (var id in ProgIds)
{
if (NativeMethods.GetActiveObject(id) is KompasObject ko)
{
_kompas = ko;
LastProgId = id;
_launchedByUs = false;
_log.LogInformation("Присоединились к запущенному КОМПАС ({ProgId})", id);
break;
}
}
// 2) запустить новый экземпляр
if (_kompas is null)
{
foreach (var id in ProgIds)
{
var type = Type.GetTypeFromProgID(id, throwOnError: false);
if (type is null) continue;
try
{
if (Activator.CreateInstance(type) is KompasObject ko)
{
_kompas = ko;
LastProgId = id;
_launchedByUs = true;
_log.LogInformation("Запустили новый КОМПАС ({ProgId})", id);
break;
}
}
catch (Exception ex)
{
_log.LogWarning(ex, "Не удалось создать экземпляр {ProgId}", id);
}
}
}
if (_kompas is null)
throw new InvalidOperationException(
"Не удалось подключиться к КОМПАС. Убедитесь, что КОМПАС-3D установлен и запускается.");
_app = (IApplication?)_kompas.ksGetApplication7()
?? throw new InvalidOperationException("ksGetApplication7() вернул null — API7 недоступен.");
_app.Visible = visible;
return BuildInfo(LastProgId);
}
private string LastProgId { get; set; } = string.Empty;
private KompasConnectionInfo BuildInfo(string progId)
{
int major = 0, minor = 0, release = 0, build = 0;
try { _app!.GetSystemVersion(out major, out minor, out release, out build); }
catch (Exception ex) { _log.LogWarning(ex, "GetSystemVersion недоступен"); }
int buildNumber = 0;
try { buildNumber = _app!.BuildNumber; }
catch (Exception ex) { _log.LogWarning(ex, "BuildNumber недоступен"); }
return new KompasConnectionInfo
{
ProgId = progId,
WasLaunched = _launchedByUs,
Version = $"{major}.{minor}.{release}",
BuildNumber = buildNumber,
Edition = progId.StartsWith("KOMPASLT", StringComparison.OrdinalIgnoreCase) ? "Home/LT" : "Full",
};
}
public void Dispose()
{
// Освобождаем COM-объекты на том же STA-потоке, на котором они созданы.
try
{
_dispatcher.InvokeAsync(() =>
{
// Quit не вызываем: не «убиваем» КОМПАС пользователя (см. docs/OPEN_QUESTIONS.md).
if (_app is not null && Marshal.IsComObject(_app)) Marshal.ReleaseComObject(_app);
if (_kompas is not null && Marshal.IsComObject(_kompas)) Marshal.ReleaseComObject(_kompas);
_app = null;
_kompas = null;
}).GetAwaiter().GetResult();
}
catch (ObjectDisposedException)
{
// Диспетчер уже остановлен — COM освободит GC-финализатор.
}
}
}
@@ -0,0 +1,111 @@
using System.Collections.Concurrent;
using System.Runtime.Versioning;
namespace Kompas.Mcp.Core.Threading;
/// <summary>
/// Выделенный STA-поток, который владеет всеми COM-вызовами к КОМПАС.
/// <para>
/// КОМПАС — однопоточный STA COM-сервер в единственном экземпляре, поэтому весь доступ
/// к нему сериализуется через этот поток. Асинхронные обработчики MCP-инструментов
/// маршалят работу сюда через <see cref="InvokeAsync{T}"/>.
/// </para>
/// <remarks>
/// v1: простая очередь задач без явного насоса сообщений Windows. Для исходящих
/// синхронных вызовов из out-of-process клиента этого достаточно (COM сам обслуживает
/// STA на время вызова). Если при интеграции появятся зависания на колбэках/диалогах —
/// заменить цикл на насос сообщений (WPF Dispatcher / нативный GetMessage). См. docs/OPEN_QUESTIONS.md.
/// </remarks>
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class KompasDispatcher : IDisposable
{
private readonly Thread _thread;
private readonly BlockingCollection<Action> _queue = new();
private readonly ManualResetEventSlim _ready = new(false);
private volatile bool _disposed;
public KompasDispatcher()
{
_thread = new Thread(ThreadProc)
{
IsBackground = true,
Name = "KompasSTA",
};
_thread.SetApartmentState(ApartmentState.STA);
_thread.Start();
_ready.Wait();
}
/// <summary>Выполняется ли вызывающий код уже на STA-потоке диспетчера.</summary>
public bool IsOnDispatcherThread => Thread.CurrentThread == _thread;
/// <summary>Поставить функцию в очередь STA-потока и дождаться результата.</summary>
public Task<T> InvokeAsync<T>(Func<T> func, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(func);
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
if (_disposed)
{
tcs.SetException(new ObjectDisposedException(nameof(KompasDispatcher)));
return tcs.Task;
}
// Уже на STA-потоке — выполняем синхронно, иначе будет дедлок (ждать себя же).
if (IsOnDispatcherThread)
{
try { tcs.SetResult(func()); }
catch (Exception ex) { tcs.SetException(ex); }
return tcs.Task;
}
try
{
_queue.Add(() =>
{
if (ct.IsCancellationRequested) { tcs.TrySetCanceled(ct); return; }
try { tcs.TrySetResult(func()); }
catch (Exception ex) { tcs.TrySetException(ex); }
});
}
catch (InvalidOperationException)
{
tcs.SetException(new ObjectDisposedException(nameof(KompasDispatcher)));
}
return tcs.Task;
}
/// <summary>Поставить действие в очередь STA-потока и дождаться завершения.</summary>
public Task InvokeAsync(Action action, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(action);
return InvokeAsync(() => { action(); return true; }, ct);
}
private void ThreadProc()
{
_ready.Set();
foreach (var work in _queue.GetConsumingEnumerable())
{
// Исключения каждой задачи уже перехвачены и переданы в её TaskCompletionSource.
work();
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_queue.CompleteAdding();
if (!_thread.Join(TimeSpan.FromSeconds(10)))
{
// STA-поток не завершился (возможно, висит COM-вызов) — оставляем как фон.
}
_queue.Dispose();
_ready.Dispose();
}
}
+54
View File
@@ -0,0 +1,54 @@
using Kompas.Mcp.Core.Threading;
namespace Kompas.Mcp.Tests;
/// <summary>Юнит-тесты STA-диспетчера. COM не требуется.</summary>
[Trait("Category", "Unit")]
public sealed class DispatcherTests
{
[Fact]
public async Task Runs_work_on_STA_thread()
{
using var d = new KompasDispatcher();
var state = await d.InvokeAsync(() => Thread.CurrentThread.GetApartmentState());
Assert.Equal(ApartmentState.STA, state);
}
[Fact]
public async Task Serializes_all_work_on_one_thread()
{
using var d = new KompasDispatcher();
var id1 = await d.InvokeAsync(() => Environment.CurrentManagedThreadId);
var id2 = await d.InvokeAsync(() => Environment.CurrentManagedThreadId);
Assert.Equal(id1, id2);
Assert.NotEqual(Environment.CurrentManagedThreadId, id1);
}
[Fact]
public async Task Propagates_exceptions_to_caller()
{
using var d = new KompasDispatcher();
await Assert.ThrowsAsync<InvalidOperationException>(
() => d.InvokeAsync<int>(() => throw new InvalidOperationException("boom")));
}
[Fact]
public async Task Throws_ObjectDisposed_after_dispose()
{
var d = new KompasDispatcher();
d.Dispose();
await Assert.ThrowsAsync<ObjectDisposedException>(() => d.InvokeAsync(() => { }));
}
[Fact]
public async Task Nested_invoke_runs_inline_without_deadlock()
{
using var d = new KompasDispatcher();
var result = await d.InvokeAsync(() =>
{
// Вложенный вызов на том же потоке должен выполниться синхронно, без дедлока.
return d.InvokeAsync(() => 42).GetAwaiter().GetResult();
});
Assert.Equal(42, result);
}
}
@@ -0,0 +1,31 @@
namespace Kompas.Mcp.Tests.Integration;
/// <summary>Интеграция: подключение к КОМПАС. Требует установленный КОМПАС-3D.</summary>
[Trait("Category", "Integration")]
[Collection(KompasCollection.Name)]
public sealed class ConnectionTests
{
private readonly KompasFixture _fx;
public ConnectionTests(KompasFixture fx) => _fx = fx;
[Fact]
public void Connects_to_kompas()
{
Assert.True(_fx.Session.IsConnected);
}
[Fact]
public void Reports_version_24()
{
Assert.False(string.IsNullOrWhiteSpace(_fx.Info.Version));
Assert.StartsWith("24", _fx.Info.Version);
}
[Fact]
public void Reports_progid_and_edition()
{
Assert.Contains("Application.5", _fx.Info.ProgId);
Assert.False(string.IsNullOrWhiteSpace(_fx.Info.Edition));
}
}
@@ -0,0 +1,35 @@
using Kompas.Mcp.Core;
using Kompas.Mcp.Core.Threading;
namespace Kompas.Mcp.Tests.Integration;
/// <summary>
/// Общая фикстура интеграционных тестов: один STA-диспетчер и одно подключение к КОМПАС
/// на весь набор тестов коллекции (КОМПАС присоединяется/запускается единожды).
/// </summary>
public sealed class KompasFixture : IDisposable
{
public KompasDispatcher Dispatcher { get; }
public KompasSession Session { get; }
public KompasConnectionInfo Info { get; }
public KompasFixture()
{
Dispatcher = new KompasDispatcher();
Session = new KompasSession(Dispatcher);
Info = Session.ConnectAsync(visible: true).GetAwaiter().GetResult();
}
public void Dispose()
{
Session.Dispose();
Dispatcher.Dispose();
}
}
/// <summary>Коллекция, позволяющая делить одно подключение между классами интеграционных тестов.</summary>
[CollectionDefinition(Name)]
public sealed class KompasCollection : ICollectionFixture<KompasFixture>
{
public const string Name = "kompas";
}
-10
View File
@@ -1,10 +0,0 @@
namespace Kompas.Mcp.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}