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:
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace Kompas.Mcp.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user