diff --git a/docs/GatewayTesting.md b/docs/GatewayTesting.md new file mode 100644 index 0000000..6412a7d --- /dev/null +++ b/docs/GatewayTesting.md @@ -0,0 +1,58 @@ +# Gateway Testing + +Gateway tests run without installed MXAccess by using fake workers, fake +transports, and in-process gRPC service fakes. Live MXAccess verification belongs +in opt-in integration tests because it depends on installed COM components and +provider state. + +## Fake Worker Harness + +`FakeWorkerHarness` in `src/MxGateway.Tests/Gateway/Workers/Fakes/` provides an +in-process worker side for named-pipe IPC tests. It uses the same +`WorkerFrameReader`, `WorkerFrameWriter`, and `WorkerEnvelope` contract as the +gateway so tests exercise real frame validation and worker-client state changes. + +Use the harness when a gateway or session test needs worker behavior without +starting `MxGateway.Worker.exe` or loading MXAccess COM. The harness scripts: + +- `WorkerHello` and `WorkerReady` startup, +- command replies with matching correlation ids, +- ordered `WorkerEvent` frames, +- `WorkerFault` frames, +- shutdown acknowledgements, +- malformed protobuf payloads and oversized frame headers, +- slow or hung workers by withholding a reply. + +Session-level tests can connect the harness to the pipe created by +`SessionWorkerClientFactory` with `ConnectToGatewayPipeAsync`. Lower-level +`WorkerClient` tests can use `CreateConnectedPairAsync` to create both pipe ends +inside the test. + +`GatewayEndToEndFakeWorkerSmokeTests` composes the real gRPC service, +`SessionManager`, `SessionWorkerClientFactory`, `WorkerClient`, and +`EventStreamService` with a scripted fake worker launcher. The smoke test covers +`OpenSession`, `Register`, `AddItem`, `Advise`, one streamed `OnDataChange` +event, and `CloseSession` without loading MXAccess COM. + +## Focused Commands + +Run the fake worker tests after changing gateway worker IPC, session startup, or +event streaming behavior: + +```bash +dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~FakeWorkerHarnessTests +dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~SessionWorkerClientFactoryFakeWorkerTests +dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~GatewayEndToEndFakeWorkerSmokeTests +``` + +Run the gateway test project after shared gateway test infrastructure changes: + +```bash +dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj +``` + +## Related Documentation + +- [Gateway Process Design](./gateway-process-design.md) +- [Worker Frame Protocol](./WorkerFrameProtocol.md) +- [MXAccess Worker Instance Detailed Design](./mxaccess-worker-instance-design.md) diff --git a/docs/gateway-process-design.md b/docs/gateway-process-design.md index 8c0036f..8bcaef8 100644 --- a/docs/gateway-process-design.md +++ b/docs/gateway-process-design.md @@ -891,6 +891,11 @@ behavior unless an explicit non-parity backend is designed. Gateway tests should be able to run without installed MXAccess by using fake workers and fake transports. +Use `FakeWorkerHarness` for tests that need real gateway-to-worker framing, +handshake, command, event, fault, or malformed-protocol behavior without loading +MXAccess COM. See [Gateway Testing](./GatewayTesting.md) for the harness scope +and focused test commands. + Focused tests: - session state transitions, diff --git a/docs/implementation-plan-mxaccess-worker.md b/docs/implementation-plan-mxaccess-worker.md index 2f6cc73..3f0d9ae 100644 --- a/docs/implementation-plan-mxaccess-worker.md +++ b/docs/implementation-plan-mxaccess-worker.md @@ -218,6 +218,8 @@ Live tests: Labels: `area:worker`, `type:feature`, `priority:p0` +Status: implemented. + Deliverables: - `AddItem`, diff --git a/docs/mxaccess-worker-instance-design.md b/docs/mxaccess-worker-instance-design.md index 7f3ea2b..05bef4b 100644 --- a/docs/mxaccess-worker-instance-design.md +++ b/docs/mxaccess-worker-instance-design.md @@ -432,6 +432,25 @@ HRESULT and converts the reply to `ProtocolStatusCode.MxaccessFailure`. `MxAccessStaSession.GetRegisteredServerHandlesAsync` returns an STA-read snapshot of tracked server handles for diagnostics and future cleanup logic. +`MxAccessCommandExecutor` also implements the item lifecycle commands: + +- `AddItem` calls `LMXProxyServerClass.AddItem` with the requested server + handle and item definition. It preserves the returned item handle in both + `ReturnValue` and `AddItemReply.ItemHandle`. +- `AddItem2` calls `LMXProxyServerClass.AddItem2` with the requested server + handle, item definition, and context string. The context string is passed to + MXAccess exactly as received. +- `RemoveItem` calls `LMXProxyServerClass.RemoveItem` with the requested server + handle and item handle. The reply has no method-specific payload because the + public MXAccess method returns `void`. + +The worker records item handles only after `AddItem` or `AddItem2` returns +normally, and removes item handles only after `RemoveItem` returns normally. +The registry does not prevalidate server or item handles, so invalid and +cross-server handle behavior remains owned by MXAccess. COM exceptions continue +through `StaCommandDispatcher`, which preserves the HRESULT and leaves +diagnostic registry state unchanged for failed cleanup calls. + ## Handle Registry The worker should track MXAccess state for diagnostics and cleanup, while still @@ -454,6 +473,8 @@ Rules: - Do not rewrite handles returned by MXAccess. - Record server handles only after `Register` succeeds. - Remove server handles only after `Unregister` succeeds. +- Record item handles only after `AddItem` or `AddItem2` succeeds. +- Remove item handles only after `RemoveItem` succeeds. - Preserve invalid-handle behavior from MXAccess. - Preserve cross-server handle behavior from MXAccess. - Use registry state for cleanup and diagnostics, not semantic correction. @@ -697,6 +718,10 @@ Live MXAccess tests: Live tests should be opt-in and clearly marked because they depend on installed MXAccess COM and provider state. +The worker test suite uses `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1` for these +tests. `AddItem` uses `TestChildObject.TestInt` by default and accepts an +override through `MXGATEWAY_LIVE_MXACCESS_ITEM`; `AddItem2` uses the captured +parity fixture shape `AddItem2("TestInt", "TestChildObject")`. ## Initial Implementation Slice diff --git a/src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs b/src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs new file mode 100644 index 0000000..7374035 --- /dev/null +++ b/src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs @@ -0,0 +1,439 @@ +using System.Collections.Concurrent; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using MxGateway.Contracts; +using MxGateway.Contracts.Proto; +using MxGateway.Server.Configuration; +using MxGateway.Server.Grpc; +using MxGateway.Server.Metrics; +using MxGateway.Server.Security.Authorization; +using MxGateway.Server.Sessions; +using MxGateway.Server.Workers; +using MxGateway.Tests.Gateway.Workers.Fakes; + +namespace MxGateway.Tests.Gateway; + +public sealed class GatewayEndToEndFakeWorkerSmokeTests +{ + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5); + private const int ServerHandle = 1001; + private const int ItemHandle = 2002; + + [Fact] + public async Task GatewayService_WithFakeWorker_CompletesSessionCommandEventAndClosePath() + { + ScriptedFakeWorkerProcessLauncher launcher = new(); + await using GatewayServiceFixture fixture = new(launcher); + + OpenSessionReply openReply = await fixture.Service.OpenSession( + new OpenSessionRequest + { + ClientSessionName = "fake-worker-e2e", + ClientCorrelationId = "open-correlation", + CommandTimeout = Duration.FromTimeSpan(TestTimeout), + }, + new TestServerCallContext()); + + RecordingServerStreamWriter eventWriter = new(); + Task streamTask = fixture.Service.StreamEvents( + new StreamEventsRequest { SessionId = openReply.SessionId }, + eventWriter, + new TestServerCallContext()); + + MxCommandReply registerReply = await fixture.Service.Invoke( + CreateRegisterRequest(openReply.SessionId), + new TestServerCallContext()); + MxCommandReply addItemReply = await fixture.Service.Invoke( + CreateAddItemRequest(openReply.SessionId, registerReply.Register.ServerHandle), + new TestServerCallContext()); + MxCommandReply adviseReply = await fixture.Service.Invoke( + CreateAdviseRequest(openReply.SessionId, registerReply.Register.ServerHandle, addItemReply.AddItem.ItemHandle), + new TestServerCallContext()); + + MxEvent dataChange = await eventWriter.WaitForFirstMessageAsync(TestTimeout); + + CloseSessionReply closeReply = await fixture.Service.CloseSession( + new CloseSessionRequest + { + SessionId = openReply.SessionId, + ClientCorrelationId = "close-correlation", + }, + new TestServerCallContext()); + + await streamTask.WaitAsync(TestTimeout); + await launcher.WorkerTask.WaitAsync(TestTimeout); + + Assert.Equal(ProtocolStatusCode.Ok, openReply.ProtocolStatus.Code); + Assert.Equal(GatewayContractInfo.DefaultBackendName, openReply.BackendName); + Assert.Equal(ScriptedFakeWorkerProcessLauncher.ProcessId, openReply.WorkerProcessId); + Assert.Equal(ProtocolStatusCode.Ok, registerReply.ProtocolStatus.Code); + Assert.Equal(ServerHandle, registerReply.Register.ServerHandle); + Assert.Equal(ProtocolStatusCode.Ok, addItemReply.ProtocolStatus.Code); + Assert.Equal(ItemHandle, addItemReply.AddItem.ItemHandle); + Assert.Equal(ProtocolStatusCode.Ok, adviseReply.ProtocolStatus.Code); + Assert.Equal(MxEventFamily.OnDataChange, dataChange.Family); + Assert.Equal(openReply.SessionId, dataChange.SessionId); + Assert.Equal(ServerHandle, dataChange.ServerHandle); + Assert.Equal(ItemHandle, dataChange.ItemHandle); + Assert.Equal("scripted-value", dataChange.Value.StringValue); + Assert.Equal(ProtocolStatusCode.Ok, closeReply.ProtocolStatus.Code); + Assert.Equal(SessionState.Closed, closeReply.FinalState); + Assert.True(launcher.Process.HasExited); + Assert.Equal( + [MxCommandKind.Register, MxCommandKind.AddItem, MxCommandKind.Advise], + launcher.CommandKinds); + } + + private static MxCommandRequest CreateRegisterRequest(string sessionId) + { + return new MxCommandRequest + { + SessionId = sessionId, + ClientCorrelationId = "register-correlation", + Command = new MxCommand + { + Kind = MxCommandKind.Register, + Register = new RegisterCommand { ClientName = "fake-worker-e2e-client" }, + }, + }; + } + + private static MxCommandRequest CreateAddItemRequest( + string sessionId, + int serverHandle) + { + return new MxCommandRequest + { + SessionId = sessionId, + ClientCorrelationId = "add-item-correlation", + Command = new MxCommand + { + Kind = MxCommandKind.AddItem, + AddItem = new AddItemCommand + { + ServerHandle = serverHandle, + ItemDefinition = "Galaxy.Tag.Value", + }, + }, + }; + } + + private static MxCommandRequest CreateAdviseRequest( + string sessionId, + int serverHandle, + int itemHandle) + { + return new MxCommandRequest + { + SessionId = sessionId, + ClientCorrelationId = "advise-correlation", + Command = new MxCommand + { + Kind = MxCommandKind.Advise, + Advise = new AdviseCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + }, + }, + }; + } + + private sealed class GatewayServiceFixture : IAsyncDisposable + { + private readonly GatewayMetrics _metrics = new(); + private readonly SessionRegistry _registry = new(); + + public GatewayServiceFixture(IWorkerProcessLauncher launcher) + { + IOptions options = Options.Create(CreateOptions()); + SessionWorkerClientFactory workerClientFactory = new( + launcher, + options, + _metrics, + NullLoggerFactory.Instance); + SessionManager sessionManager = new( + _registry, + workerClientFactory, + options, + _metrics, + logger: NullLogger.Instance); + MxAccessGrpcMapper mapper = new(); + EventStreamService eventStreamService = new( + sessionManager, + options, + mapper, + _metrics, + NullLogger.Instance); + + Service = new MxAccessGatewayService( + sessionManager, + new GatewayRequestIdentityAccessor(), + new MxAccessGrpcRequestValidator(), + mapper, + eventStreamService, + NullLogger.Instance); + } + + public MxAccessGatewayService Service { get; } + + public async ValueTask DisposeAsync() + { + foreach (GatewaySession session in _registry.Snapshot()) + { + await session.DisposeAsync(); + } + + _metrics.Dispose(); + } + + private static GatewayOptions CreateOptions() + { + return new GatewayOptions + { + Worker = new WorkerOptions + { + StartupTimeoutSeconds = 5, + ShutdownTimeoutSeconds = 5, + HeartbeatIntervalSeconds = 30, + HeartbeatGraceSeconds = 30, + MaxMessageBytes = WorkerFrameProtocolOptions.DefaultMaxMessageBytes, + }, + Sessions = new SessionOptions + { + DefaultCommandTimeoutSeconds = 5, + MaxSessions = 4, + }, + Events = new EventOptions + { + QueueCapacity = 16, + }, + }; + } + } + + private sealed class ScriptedFakeWorkerProcessLauncher : IWorkerProcessLauncher + { + public const int ProcessId = 4680; + private readonly ConcurrentQueue _commandKinds = new(); + + public FakeWorkerProcess Process { get; } = new(ProcessId); + + public IReadOnlyCollection CommandKinds => _commandKinds.ToArray(); + + public Task WorkerTask { get; private set; } = Task.CompletedTask; + + public Task LaunchAsync( + WorkerProcessLaunchRequest request, + CancellationToken cancellationToken = default) + { + WorkerTask = RunWorkerAsync(request, cancellationToken); + + return Task.FromResult(new WorkerProcessHandle( + Process, + new WorkerProcessCommandLine("fake-worker.exe", []), + DateTimeOffset.UtcNow)); + } + + private async Task RunWorkerAsync( + WorkerProcessLaunchRequest request, + CancellationToken cancellationToken) + { + await using FakeWorkerHarness harness = await FakeWorkerHarness.ConnectToGatewayPipeAsync( + request.SessionId, + request.Nonce, + request.PipeName, + request.ProtocolVersion, + cancellationToken: cancellationToken).ConfigureAwait(false); + await harness.CompleteStartupAsync(ProcessId, cancellationToken: cancellationToken).ConfigureAwait(false); + + while (!cancellationToken.IsCancellationRequested) + { + WorkerEnvelope envelope = await harness.ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false); + if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerCommand) + { + await ReplyToCommandAsync(harness, envelope, cancellationToken).ConfigureAwait(false); + continue; + } + + if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerShutdown) + { + await harness.SendShutdownAckAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + Process.MarkExited(0); + return; + } + + throw new InvalidOperationException($"Unexpected gateway envelope {envelope.BodyCase}."); + } + } + + private async Task ReplyToCommandAsync( + FakeWorkerHarness harness, + WorkerEnvelope commandEnvelope, + CancellationToken cancellationToken) + { + MxCommand command = commandEnvelope.WorkerCommand.Command; + _commandKinds.Enqueue(command.Kind); + + await harness.ReplyToCommandAsync( + commandEnvelope, + configureReply: reply => ConfigureReply(reply, command.Kind), + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (command.Kind == MxCommandKind.Advise) + { + await harness.EmitEventAsync( + MxEventFamily.OnDataChange, + cancellationToken, + mxEvent => + { + mxEvent.ServerHandle = command.Advise.ServerHandle; + mxEvent.ItemHandle = command.Advise.ItemHandle; + mxEvent.Quality = 192; + mxEvent.Value = new MxValue + { + DataType = MxDataType.String, + StringValue = "scripted-value", + }; + mxEvent.OnDataChange = new OnDataChangeEvent(); + }).ConfigureAwait(false); + } + } + + private static void ConfigureReply( + MxCommandReply reply, + MxCommandKind kind) + { + switch (kind) + { + case MxCommandKind.Register: + reply.Register = new RegisterReply { ServerHandle = ServerHandle }; + break; + case MxCommandKind.AddItem: + reply.AddItem = new AddItemReply { ItemHandle = ItemHandle }; + break; + } + } + } + + private sealed class FakeWorkerProcess(int processId) : IWorkerProcess + { + public int Id { get; } = processId; + + public bool HasExited { get; private set; } + + public int? ExitCode { get; private set; } + + public ValueTask WaitForExitAsync(CancellationToken cancellationToken) + { + HasExited = true; + ExitCode ??= 0; + return ValueTask.CompletedTask; + } + + public void Kill(bool entireProcessTree) + { + MarkExited(-1); + } + + public void Dispose() + { + } + + public void MarkExited(int exitCode) + { + HasExited = true; + ExitCode = exitCode; + } + } + + private sealed class RecordingServerStreamWriter : IServerStreamWriter + { + private readonly object _syncRoot = new(); + private readonly TaskCompletionSource _firstMessage = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly List _messages = []; + + public IReadOnlyList Messages + { + get + { + lock (_syncRoot) + { + return _messages.ToArray(); + } + } + } + + public WriteOptions? WriteOptions { get; set; } + + public Task WriteAsync(T message) + { + lock (_syncRoot) + { + _messages.Add(message); + } + + _firstMessage.TrySetResult(message); + return Task.CompletedTask; + } + + public async Task WaitForFirstMessageAsync(TimeSpan timeout) + { + return await _firstMessage.Task.WaitAsync(timeout).ConfigureAwait(false); + } + } + + private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext + { + private readonly Metadata _requestHeaders = []; + private readonly Metadata _responseTrailers = []; + private readonly Dictionary _userState = []; + private Status _status; + private WriteOptions? _writeOptions; + + protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test"; + + protected override string HostCore => "localhost"; + + protected override string PeerCore => "ipv4:127.0.0.1:5000"; + + protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1); + + protected override Metadata RequestHeadersCore => _requestHeaders; + + protected override CancellationToken CancellationTokenCore => cancellationToken; + + protected override Metadata ResponseTrailersCore => _responseTrailers; + + protected override Status StatusCore + { + get => _status; + set => _status = value; + } + + protected override WriteOptions? WriteOptionsCore + { + get => _writeOptions; + set => _writeOptions = value; + } + + protected override AuthContext AuthContextCore { get; } = new( + string.Empty, + new Dictionary>(StringComparer.Ordinal)); + + protected override IDictionary UserStateCore => _userState; + + protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) + { + return Task.CompletedTask; + } + + protected override ContextPropagationToken CreatePropagationTokenCore( + ContextPropagationOptions? options) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/MxGateway.Tests/Gateway/Sessions/SessionWorkerClientFactoryFakeWorkerTests.cs b/src/MxGateway.Tests/Gateway/Sessions/SessionWorkerClientFactoryFakeWorkerTests.cs new file mode 100644 index 0000000..625dd38 --- /dev/null +++ b/src/MxGateway.Tests/Gateway/Sessions/SessionWorkerClientFactoryFakeWorkerTests.cs @@ -0,0 +1,216 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using MxGateway.Contracts; +using MxGateway.Contracts.Proto; +using MxGateway.Server.Configuration; +using MxGateway.Server.Metrics; +using MxGateway.Server.Sessions; +using MxGateway.Server.Workers; +using MxGateway.Tests.Gateway.Workers.Fakes; + +namespace MxGateway.Tests.Gateway.Sessions; + +public sealed class SessionWorkerClientFactoryFakeWorkerTests +{ + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5); + + [Fact] + public async Task CreateAsync_WithScriptedFakeWorker_ReturnsReadyClient() + { + ScriptedFakeWorkerProcessLauncher launcher = new(); + using GatewayMetrics metrics = new(); + SessionWorkerClientFactory factory = new( + launcher, + Options.Create(CreateOptions()), + metrics, + NullLoggerFactory.Instance); + GatewaySession session = CreateSession(); + + await using IWorkerClient workerClient = await factory.CreateAsync( + session, + CancellationToken.None); + + Assert.Equal(WorkerClientState.Ready, workerClient.State); + Assert.Equal(ScriptedFakeWorkerProcessLauncher.ProcessId, workerClient.ProcessId); + Assert.NotNull(launcher.Harness); + + Task invokeTask = workerClient.InvokeAsync( + CreateCommand(MxCommandKind.Ping), + TestTimeout, + CancellationToken.None); + WorkerEnvelope commandEnvelope = await launcher.Harness.ReadCommandAsync(); + await launcher.Harness.ReplyToCommandAsync(commandEnvelope); + WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout); + + Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind); + Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code); + } + + [Fact] + public async Task CreateAsync_WhenFakeWorkerStartupFails_ThrowsWorkerClientException() + { + FailingStartupWorkerProcessLauncher launcher = new(); + using GatewayMetrics metrics = new(); + SessionWorkerClientFactory factory = new( + launcher, + Options.Create(CreateOptions()), + metrics, + NullLoggerFactory.Instance); + GatewaySession session = CreateSession(); + + WorkerClientException exception = await Assert.ThrowsAsync( + async () => await factory.CreateAsync(session, CancellationToken.None).WaitAsync(TestTimeout)); + + Assert.Equal(WorkerClientErrorCode.ProtocolViolation, exception.ErrorCode); + Assert.True(launcher.Process.IsDisposed); + } + + private static GatewayOptions CreateOptions() + { + return new GatewayOptions + { + Worker = new WorkerOptions + { + StartupTimeoutSeconds = 5, + ShutdownTimeoutSeconds = 5, + HeartbeatIntervalSeconds = 30, + HeartbeatGraceSeconds = 30, + MaxMessageBytes = WorkerFrameProtocolOptions.DefaultMaxMessageBytes, + }, + Events = new EventOptions + { + QueueCapacity = 16, + }, + }; + } + + private static GatewaySession CreateSession() + { + return new GatewaySession( + FakeWorkerHarness.DefaultSessionId, + GatewayContractInfo.DefaultBackendName, + $"mxaccessgw-session-fake-worker-{Guid.NewGuid():N}", + FakeWorkerHarness.DefaultNonce, + "test-client", + "fake-worker-session-test", + "client-correlation-1", + TestTimeout, + TestTimeout, + TestTimeout, + DateTimeOffset.UtcNow); + } + + private static WorkerCommand CreateCommand(MxCommandKind kind) + { + return new WorkerCommand + { + Command = new MxCommand + { + Kind = kind, + }, + }; + } + + private sealed class ScriptedFakeWorkerProcessLauncher : IWorkerProcessLauncher + { + public const int ProcessId = 2468; + private readonly FakeWorkerProcess _process = new(ProcessId); + + public FakeWorkerHarness? Harness { get; private set; } + + public Task LaunchAsync( + WorkerProcessLaunchRequest request, + CancellationToken cancellationToken = default) + { + _ = RunWorkerAsync(request, cancellationToken); + + return Task.FromResult(CreateHandle(_process)); + } + + private async Task RunWorkerAsync( + WorkerProcessLaunchRequest request, + CancellationToken cancellationToken) + { + Harness = await FakeWorkerHarness.ConnectToGatewayPipeAsync( + request.SessionId, + request.Nonce, + request.PipeName, + request.ProtocolVersion, + cancellationToken: cancellationToken).ConfigureAwait(false); + await Harness.CompleteStartupAsync(ProcessId, cancellationToken: cancellationToken).ConfigureAwait(false); + } + } + + private sealed class FailingStartupWorkerProcessLauncher : IWorkerProcessLauncher + { + public FakeWorkerProcess Process { get; } = new(processId: 3579); + + public Task LaunchAsync( + WorkerProcessLaunchRequest request, + CancellationToken cancellationToken = default) + { + _ = RunWorkerAsync(request, cancellationToken); + + return Task.FromResult(CreateHandle(Process)); + } + + private async Task RunWorkerAsync( + WorkerProcessLaunchRequest request, + CancellationToken cancellationToken) + { + await using FakeWorkerHarness harness = await FakeWorkerHarness.ConnectToGatewayPipeAsync( + request.SessionId, + request.Nonce, + request.PipeName, + request.ProtocolVersion, + cancellationToken: cancellationToken).ConfigureAwait(false); + _ = await harness.ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false); + await harness.SendWorkerHelloAsync( + workerProcessId: Process.Id, + workerProtocolVersion: request.ProtocolVersion + 1, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + } + + private static WorkerProcessHandle CreateHandle(IWorkerProcess process) + { + return new WorkerProcessHandle( + process, + new WorkerProcessCommandLine("fake-worker.exe", []), + DateTimeOffset.UtcNow); + } + + private sealed class FakeWorkerProcess(int processId) : IWorkerProcess + { + private bool _disposed; + + public int Id { get; } = processId; + + public bool HasExited { get; private set; } + + public int? ExitCode { get; private set; } + + public int KillCount { get; private set; } + + public ValueTask WaitForExitAsync(CancellationToken cancellationToken) + { + HasExited = true; + ExitCode = 0; + return ValueTask.CompletedTask; + } + + public void Kill(bool entireProcessTree) + { + KillCount++; + HasExited = true; + ExitCode = -1; + } + + public void Dispose() + { + _disposed = true; + } + + public bool IsDisposed => _disposed; + } +} diff --git a/src/MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs b/src/MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs new file mode 100644 index 0000000..b5daed4 --- /dev/null +++ b/src/MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs @@ -0,0 +1,190 @@ +using MxGateway.Contracts; +using MxGateway.Contracts.Proto; +using MxGateway.Server.Workers; +using MxGateway.Tests.Gateway.Workers.Fakes; + +namespace MxGateway.Tests.Gateway.Workers; + +public sealed class FakeWorkerHarnessTests +{ + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5); + + [Fact] + public async Task CompleteStartupAsync_WithHelloAndReady_TransitionsClientToReady() + { + await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync(); + await using WorkerClient client = fakeWorker.CreateClient(); + + Task startTask = client.StartAsync(CancellationToken.None); + WorkerEnvelope gatewayHello = await fakeWorker.CompleteStartupAsync(); + await startTask.WaitAsync(TestTimeout); + + Assert.Equal(WorkerEnvelope.BodyOneofCase.GatewayHello, gatewayHello.BodyCase); + Assert.Equal(FakeWorkerHarness.DefaultNonce, gatewayHello.GatewayHello.Nonce); + Assert.Equal(WorkerClientState.Ready, client.State); + Assert.Equal(FakeWorkerHarness.DefaultWorkerProcessId, client.ProcessId); + } + + [Fact] + public async Task StartAsync_WithProtocolMismatch_FailsStartup() + { + await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync(); + await using WorkerClient client = fakeWorker.CreateClient(); + + Task startTask = client.StartAsync(CancellationToken.None); + WorkerEnvelope gatewayHello = await fakeWorker.ReadGatewayEnvelopeAsync(); + Assert.Equal(WorkerEnvelope.BodyOneofCase.GatewayHello, gatewayHello.BodyCase); + await fakeWorker.SendWorkerHelloAsync( + workerProtocolVersion: GatewayContractInfo.WorkerProtocolVersion + 1); + + WorkerClientException exception = await Assert.ThrowsAsync( + async () => await startTask.WaitAsync(TestTimeout)); + + Assert.Equal(WorkerClientErrorCode.ProtocolViolation, exception.ErrorCode); + } + + [Fact] + public async Task InvokeAsync_WithScriptedReply_CompletesCommand() + { + await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync(); + await using WorkerClient client = fakeWorker.CreateClient(); + await StartClientAsync(fakeWorker, client); + + Task invokeTask = client.InvokeAsync( + CreateCommand(MxCommandKind.Ping), + TestTimeout, + CancellationToken.None); + WorkerEnvelope commandEnvelope = await fakeWorker.ReadCommandAsync(); + await fakeWorker.ReplyToCommandAsync(commandEnvelope); + + WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout); + + Assert.Equal(commandEnvelope.CorrelationId, reply.Reply.CorrelationId); + Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind); + Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code); + } + + [Fact] + public async Task ReadEventsAsync_WithScriptedEvents_YieldsOrderedEvents() + { + await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync(); + await using WorkerClient client = fakeWorker.CreateClient(); + await StartClientAsync(fakeWorker, client); + using CancellationTokenSource cancellationTokenSource = new(TestTimeout); + + await using IAsyncEnumerator events = + client.ReadEventsAsync(cancellationTokenSource.Token).GetAsyncEnumerator(cancellationTokenSource.Token); + + await fakeWorker.EmitEventAsync(MxEventFamily.OnDataChange, cancellationTokenSource.Token); + await fakeWorker.EmitEventAsync(MxEventFamily.OperationComplete, cancellationTokenSource.Token); + + Assert.True(await events.MoveNextAsync()); + Assert.Equal((ulong)3, events.Current.Event.WorkerSequence); + Assert.Equal(MxEventFamily.OnDataChange, events.Current.Event.Family); + + Assert.True(await events.MoveNextAsync()); + Assert.Equal((ulong)4, events.Current.Event.WorkerSequence); + Assert.Equal(MxEventFamily.OperationComplete, events.Current.Event.Family); + } + + [Fact] + public async Task ReadLoop_WithScriptedFault_FaultsClient() + { + await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync(); + await using WorkerClient client = fakeWorker.CreateClient(); + await StartClientAsync(fakeWorker, client); + + await fakeWorker.EmitFaultAsync( + WorkerFaultCategory.MxaccessCommandFailed, + "scripted MXAccess command fault"); + + await WaitUntilAsync( + () => client.State == WorkerClientState.Faulted, + TestTimeout); + + Assert.Equal(WorkerClientState.Faulted, client.State); + } + + [Fact] + public async Task InvokeAsync_WithHungWorker_TimesOutPendingCommand() + { + await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync(); + await using WorkerClient client = fakeWorker.CreateClient(); + await StartClientAsync(fakeWorker, client); + + Task invokeTask = client.InvokeAsync( + CreateCommand(MxCommandKind.Ping), + TimeSpan.FromMilliseconds(50), + CancellationToken.None); + WorkerEnvelope commandEnvelope = await fakeWorker.ReadCommandAsync(); + + WorkerClientException exception = await Assert.ThrowsAsync( + async () => await invokeTask.WaitAsync(TestTimeout)); + + Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCommand, commandEnvelope.BodyCase); + Assert.Equal(WorkerClientErrorCode.CommandTimeout, exception.ErrorCode); + } + + [Fact] + public async Task ReadLoop_WithMalformedFrame_FaultsClient() + { + await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync(); + await using WorkerClient client = fakeWorker.CreateClient(); + await StartClientAsync(fakeWorker, client); + + await fakeWorker.WriteMalformedPayloadAsync(new byte[] { 0x08, 0x96, 0x01 }); + + await WaitUntilAsync( + () => client.State == WorkerClientState.Faulted, + TestTimeout); + + Assert.Equal(WorkerClientState.Faulted, client.State); + } + + [Fact] + public async Task ShutdownAsync_WithShutdownAck_ClosesClient() + { + await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync(); + await using WorkerClient client = fakeWorker.CreateClient(); + await StartClientAsync(fakeWorker, client); + + Task shutdownTask = client.ShutdownAsync(TestTimeout, CancellationToken.None); + WorkerEnvelope shutdownEnvelope = await fakeWorker.ReadShutdownAsync(); + await fakeWorker.SendShutdownAckAsync(); + await shutdownTask.WaitAsync(TestTimeout); + + Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerShutdown, shutdownEnvelope.BodyCase); + Assert.Equal(WorkerClientState.Closed, client.State); + } + + private static async Task StartClientAsync( + FakeWorkerHarness fakeWorker, + WorkerClient client) + { + Task startTask = client.StartAsync(CancellationToken.None); + await fakeWorker.CompleteStartupAsync().ConfigureAwait(false); + await startTask.WaitAsync(TestTimeout).ConfigureAwait(false); + } + + private static WorkerCommand CreateCommand(MxCommandKind kind) + { + return new WorkerCommand + { + Command = new MxCommand + { + Kind = kind, + }, + }; + } + + private static async Task WaitUntilAsync( + Func predicate, + TimeSpan timeout) + { + using CancellationTokenSource cancellationTokenSource = new(timeout); + while (!predicate()) + { + await Task.Delay(TimeSpan.FromMilliseconds(10), cancellationTokenSource.Token); + } + } +} diff --git a/src/MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs b/src/MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs new file mode 100644 index 0000000..5981618 --- /dev/null +++ b/src/MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs @@ -0,0 +1,386 @@ +using System.Buffers.Binary; +using System.IO.Pipes; +using Google.Protobuf.WellKnownTypes; +using MxGateway.Contracts; +using MxGateway.Contracts.Proto; +using MxGateway.Server.Metrics; +using MxGateway.Server.Workers; + +namespace MxGateway.Tests.Gateway.Workers.Fakes; + +public sealed class FakeWorkerHarness : IAsyncDisposable +{ + public const string DefaultSessionId = "session-fake-worker"; + public const string DefaultNonce = "nonce-fake-worker"; + public const int DefaultWorkerProcessId = 9321; + + private readonly NamedPipeServerStream? _gatewayStream; + private readonly NamedPipeClientStream _workerStream; + private readonly WorkerFrameProtocolOptions _frameOptions; + private readonly WorkerFrameReader _reader; + private readonly WorkerFrameWriter _writer; + private bool _workerSideDisposed; + + private FakeWorkerHarness( + string sessionId, + string nonce, + NamedPipeServerStream? gatewayStream, + NamedPipeClientStream workerStream, + WorkerFrameProtocolOptions frameOptions) + { + SessionId = sessionId; + Nonce = nonce; + _gatewayStream = gatewayStream; + _workerStream = workerStream; + _frameOptions = frameOptions; + _reader = new WorkerFrameReader(_workerStream, frameOptions); + _writer = new WorkerFrameWriter(_workerStream, frameOptions); + } + + public string SessionId { get; } + + public string Nonce { get; } + + public ulong NextWorkerSequence { get; private set; } + + public static async Task CreateConnectedPairAsync( + string sessionId = DefaultSessionId, + string nonce = DefaultNonce, + uint protocolVersion = GatewayContractInfo.WorkerProtocolVersion, + int maxMessageBytes = WorkerFrameProtocolOptions.DefaultMaxMessageBytes, + CancellationToken cancellationToken = default) + { + string pipeName = $"mxaccessgw-fake-worker-{Guid.NewGuid():N}"; + NamedPipeServerStream gatewayStream = new( + pipeName, + PipeDirection.InOut, + maxNumberOfServerInstances: 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous); + NamedPipeClientStream workerStream = CreateWorkerStream(pipeName); + + Task waitForConnectionTask = gatewayStream.WaitForConnectionAsync(cancellationToken); + await workerStream.ConnectAsync(cancellationToken).ConfigureAwait(false); + await waitForConnectionTask.ConfigureAwait(false); + + return new FakeWorkerHarness( + sessionId, + nonce, + gatewayStream, + workerStream, + new WorkerFrameProtocolOptions(sessionId, protocolVersion, maxMessageBytes)); + } + + public static async Task ConnectToGatewayPipeAsync( + string sessionId, + string nonce, + string pipeName, + uint protocolVersion = GatewayContractInfo.WorkerProtocolVersion, + int maxMessageBytes = WorkerFrameProtocolOptions.DefaultMaxMessageBytes, + CancellationToken cancellationToken = default) + { + NamedPipeClientStream workerStream = CreateWorkerStream(pipeName); + await workerStream.ConnectAsync(cancellationToken).ConfigureAwait(false); + + return new FakeWorkerHarness( + sessionId, + nonce, + gatewayStream: null, + workerStream, + new WorkerFrameProtocolOptions(sessionId, protocolVersion, maxMessageBytes)); + } + + public WorkerClient CreateClient( + WorkerClientOptions? options = null, + GatewayMetrics? metrics = null, + TimeProvider? timeProvider = null) + { + if (_gatewayStream is null) + { + throw new InvalidOperationException("This fake worker is connected to a gateway-owned pipe."); + } + + WorkerClientConnection connection = new( + SessionId, + Nonce, + _gatewayStream, + _frameOptions); + + return new WorkerClient(connection, options, metrics, timeProvider); + } + + public async Task CompleteStartupAsync( + int workerProcessId = DefaultWorkerProcessId, + string workerVersion = "fake-worker", + string mxaccessProgid = "LMXProxy.LMXProxyServer.1", + string mxaccessClsid = "{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}", + CancellationToken cancellationToken = default) + { + WorkerEnvelope gatewayHello = await ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false); + if (gatewayHello.BodyCase != WorkerEnvelope.BodyOneofCase.GatewayHello) + { + throw new InvalidOperationException($"Expected GatewayHello but received {gatewayHello.BodyCase}."); + } + + await SendWorkerHelloAsync( + workerProcessId, + workerVersion, + cancellationToken: cancellationToken).ConfigureAwait(false); + await SendWorkerReadyAsync( + workerProcessId, + mxaccessProgid, + mxaccessClsid, + cancellationToken).ConfigureAwait(false); + + return gatewayHello; + } + + public async Task ReadGatewayEnvelopeAsync(CancellationToken cancellationToken = default) + { + return await _reader.ReadAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task ReadCommandAsync(CancellationToken cancellationToken = default) + { + WorkerEnvelope envelope = await ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false); + if (envelope.BodyCase != WorkerEnvelope.BodyOneofCase.WorkerCommand) + { + throw new InvalidOperationException($"Expected WorkerCommand but received {envelope.BodyCase}."); + } + + return envelope; + } + + public async Task ReadShutdownAsync(CancellationToken cancellationToken = default) + { + WorkerEnvelope envelope = await ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false); + if (envelope.BodyCase != WorkerEnvelope.BodyOneofCase.WorkerShutdown) + { + throw new InvalidOperationException($"Expected WorkerShutdown but received {envelope.BodyCase}."); + } + + return envelope; + } + + public async Task SendWorkerHelloAsync( + int workerProcessId = DefaultWorkerProcessId, + string workerVersion = "fake-worker", + uint? workerProtocolVersion = null, + string? nonce = null, + CancellationToken cancellationToken = default) + { + await _writer.WriteAsync( + CreateEnvelope( + correlationId: string.Empty, + envelope => envelope.WorkerHello = new WorkerHello + { + ProtocolVersion = workerProtocolVersion ?? _frameOptions.ProtocolVersion, + Nonce = nonce ?? Nonce, + WorkerProcessId = workerProcessId, + WorkerVersion = workerVersion, + }), + cancellationToken).ConfigureAwait(false); + } + + public async Task SendWorkerReadyAsync( + int workerProcessId = DefaultWorkerProcessId, + string mxaccessProgid = "LMXProxy.LMXProxyServer.1", + string mxaccessClsid = "{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}", + CancellationToken cancellationToken = default) + { + await _writer.WriteAsync( + CreateEnvelope( + correlationId: string.Empty, + envelope => envelope.WorkerReady = new WorkerReady + { + WorkerProcessId = workerProcessId, + MxaccessProgid = mxaccessProgid, + MxaccessClsid = mxaccessClsid, + ReadyTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }), + cancellationToken).ConfigureAwait(false); + } + + public async Task ReplyToCommandAsync( + WorkerEnvelope commandEnvelope, + ProtocolStatusCode statusCode = ProtocolStatusCode.Ok, + string statusMessage = "OK", + Action? configureReply = null, + CancellationToken cancellationToken = default) + { + if (commandEnvelope.BodyCase != WorkerEnvelope.BodyOneofCase.WorkerCommand) + { + throw new ArgumentException("Command envelope must contain WorkerCommand.", nameof(commandEnvelope)); + } + + MxCommandKind kind = commandEnvelope.WorkerCommand.Command?.Kind ?? MxCommandKind.Unspecified; + MxCommandReply reply = new() + { + SessionId = SessionId, + CorrelationId = commandEnvelope.CorrelationId, + Kind = kind, + ProtocolStatus = new ProtocolStatus + { + Code = statusCode, + Message = statusMessage, + }, + }; + configureReply?.Invoke(reply); + + await _writer.WriteAsync( + CreateEnvelope( + commandEnvelope.CorrelationId, + envelope => envelope.WorkerCommandReply = new WorkerCommandReply + { + Reply = reply, + CompletedTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }), + cancellationToken).ConfigureAwait(false); + } + + public async Task EmitEventAsync( + MxEventFamily family, + CancellationToken cancellationToken = default, + Action? configureEvent = null) + { + ulong sequence = NextWorkerSequence + 1; + MxEvent mxEvent = new() + { + SessionId = SessionId, + Family = family, + WorkerSequence = sequence, + WorkerTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }; + configureEvent?.Invoke(mxEvent); + + await _writer.WriteAsync( + CreateEnvelope( + correlationId: string.Empty, + envelope => envelope.WorkerEvent = new WorkerEvent + { + Event = mxEvent, + }), + cancellationToken).ConfigureAwait(false); + } + + public async Task EmitFaultAsync( + WorkerFaultCategory category, + string diagnosticMessage, + CancellationToken cancellationToken = default) + { + await _writer.WriteAsync( + CreateEnvelope( + correlationId: string.Empty, + envelope => envelope.WorkerFault = new WorkerFault + { + Category = category, + DiagnosticMessage = diagnosticMessage, + ProtocolStatus = new ProtocolStatus + { + Code = ProtocolStatusCode.WorkerUnavailable, + Message = diagnosticMessage, + }, + }), + cancellationToken).ConfigureAwait(false); + } + + public async Task SendShutdownAckAsync( + ProtocolStatusCode statusCode = ProtocolStatusCode.Ok, + CancellationToken cancellationToken = default) + { + await _writer.WriteAsync( + CreateEnvelope( + correlationId: string.Empty, + envelope => envelope.WorkerShutdownAck = new WorkerShutdownAck + { + Status = new ProtocolStatus + { + Code = statusCode, + Message = statusCode.ToString(), + }, + }), + cancellationToken).ConfigureAwait(false); + } + + public async Task WriteMalformedPayloadAsync( + ReadOnlyMemory payload, + CancellationToken cancellationToken = default) + { + if (payload.IsEmpty) + { + throw new ArgumentException("Malformed payload must include at least one byte.", nameof(payload)); + } + + byte[] lengthPrefix = new byte[sizeof(uint)]; + BinaryPrimitives.WriteUInt32LittleEndian(lengthPrefix, (uint)payload.Length); + await _workerStream.WriteAsync(lengthPrefix, cancellationToken).ConfigureAwait(false); + await _workerStream.WriteAsync(payload, cancellationToken).ConfigureAwait(false); + } + + public async Task WriteOversizedFrameHeaderAsync( + uint payloadLength, + CancellationToken cancellationToken = default) + { + if (payloadLength <= _frameOptions.MaxMessageBytes) + { + throw new ArgumentOutOfRangeException( + nameof(payloadLength), + payloadLength, + "Payload length must exceed the configured maximum."); + } + + byte[] lengthPrefix = new byte[sizeof(uint)]; + BinaryPrimitives.WriteUInt32LittleEndian(lengthPrefix, payloadLength); + await _workerStream.WriteAsync(lengthPrefix, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask DisposeWorkerSideAsync() + { + if (_workerSideDisposed) + { + return; + } + + await _workerStream.DisposeAsync().ConfigureAwait(false); + _workerSideDisposed = true; + } + + public async ValueTask DisposeAsync() + { + await DisposeWorkerSideAsync().ConfigureAwait(false); + if (_gatewayStream is not null) + { + await _gatewayStream.DisposeAsync().ConfigureAwait(false); + } + } + + private WorkerEnvelope CreateEnvelope( + string correlationId, + Action setBody) + { + WorkerEnvelope envelope = new() + { + ProtocolVersion = _frameOptions.ProtocolVersion, + SessionId = SessionId, + Sequence = AdvanceSequence(), + CorrelationId = correlationId, + }; + setBody(envelope); + + return envelope; + } + + private ulong AdvanceSequence() + { + return ++NextWorkerSequence; + } + + private static NamedPipeClientStream CreateWorkerStream(string pipeName) + { + return new NamedPipeClientStream( + ".", + pipeName, + PipeDirection.InOut, + PipeOptions.Asynchronous); + } +} diff --git a/src/MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs b/src/MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs index 1a42f49..609f1f6 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs +++ b/src/MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs @@ -78,6 +78,166 @@ public sealed class MxAccessCommandExecutorTests Assert.Equal(44, registeredServerHandle.ServerHandle); } + [Fact] + public async Task DispatchAsync_AddItem_CallsMxAccessOnStaAndTracksItemHandle() + { + FakeMxAccessComObject fakeComObject = new( + registerHandle: 46, + addItemHandle: 501); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + await session.StartAsync(workerProcessId: 1234); + await session.DispatchAsync(CreateRegisterCommand("register-before-add", "client-a")); + + MxCommandReply reply = await session.DispatchAsync(CreateAddItemCommand( + "add-item", + 46, + "Galaxy.Tag.Value")); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.True(reply.HasHresult); + Assert.Equal(0, reply.Hresult); + Assert.Equal(501, reply.AddItem.ItemHandle); + Assert.Equal(MxDataType.Integer, reply.ReturnValue.DataType); + Assert.Equal(501, reply.ReturnValue.Int32Value); + Assert.Equal(46, fakeComObject.AddItemServerHandle); + Assert.Equal("Galaxy.Tag.Value", fakeComObject.AddItemDefinition); + Assert.Equal(runtime.StaThreadId, fakeComObject.AddItemThreadId); + + RegisteredItemHandle registeredItemHandle = Assert.Single( + await session.GetRegisteredItemHandlesAsync()); + Assert.Equal(46, registeredItemHandle.ServerHandle); + Assert.Equal(501, registeredItemHandle.ItemHandle); + Assert.Equal("Galaxy.Tag.Value", registeredItemHandle.ItemDefinition); + Assert.Equal(string.Empty, registeredItemHandle.ItemContext); + Assert.False(registeredItemHandle.HasItemContext); + } + + [Fact] + public async Task DispatchAsync_AddItem2_PassesContextExactlyAndTracksItemHandle() + { + FakeMxAccessComObject fakeComObject = new( + registerHandle: 47, + addItem2Handle: 502); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + await session.StartAsync(workerProcessId: 1234); + await session.DispatchAsync(CreateRegisterCommand("register-before-add2", "client-a")); + + MxCommandReply reply = await session.DispatchAsync(CreateAddItem2Command( + "add-item2", + 47, + "TestInt", + "TestChildObject")); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.Equal(502, reply.AddItem2.ItemHandle); + Assert.Equal(MxDataType.Integer, reply.ReturnValue.DataType); + Assert.Equal(502, reply.ReturnValue.Int32Value); + Assert.Equal(47, fakeComObject.AddItem2ServerHandle); + Assert.Equal("TestInt", fakeComObject.AddItem2Definition); + Assert.Equal("TestChildObject", fakeComObject.AddItem2Context); + Assert.Equal(runtime.StaThreadId, fakeComObject.AddItem2ThreadId); + + RegisteredItemHandle registeredItemHandle = Assert.Single( + await session.GetRegisteredItemHandlesAsync()); + Assert.Equal(47, registeredItemHandle.ServerHandle); + Assert.Equal(502, registeredItemHandle.ItemHandle); + Assert.Equal("TestInt", registeredItemHandle.ItemDefinition); + Assert.Equal("TestChildObject", registeredItemHandle.ItemContext); + Assert.True(registeredItemHandle.HasItemContext); + } + + [Fact] + public async Task DispatchAsync_RemoveItem_CallsMxAccessOnStaAndRemovesTrackedItemHandle() + { + FakeMxAccessComObject fakeComObject = new( + registerHandle: 48, + addItemHandle: 503); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + await session.StartAsync(workerProcessId: 1234); + await session.DispatchAsync(CreateRegisterCommand("register-before-remove", "client-a")); + await session.DispatchAsync(CreateAddItemCommand("add-before-remove", 48, "Galaxy.Tag.Value")); + + MxCommandReply reply = await session.DispatchAsync(CreateRemoveItemCommand( + "remove-item", + 48, + 503)); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.True(reply.HasHresult); + Assert.Equal(0, reply.Hresult); + Assert.Equal(48, fakeComObject.RemoveItemServerHandle); + Assert.Equal(503, fakeComObject.RemovedItemHandle); + Assert.Equal(runtime.StaThreadId, fakeComObject.RemoveItemThreadId); + Assert.Empty(await session.GetRegisteredItemHandlesAsync()); + } + + [Fact] + public async Task DispatchAsync_RemoveItemWithCrossServerHandle_PreservesHResultAndKeepsTrackedItemHandle() + { + const int hresult = unchecked((int)0x80070057); + FakeMxAccessComObject fakeComObject = new( + registerHandle: 49, + addItemHandle: 504, + removeItemException: new COMException("Invalid item handle.", hresult)); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + await session.StartAsync(workerProcessId: 1234); + await session.DispatchAsync(CreateRegisterCommand("register-before-remove-failure", "client-a")); + await session.DispatchAsync(CreateAddItemCommand("add-before-remove-failure", 49, "Galaxy.Tag.Value")); + + MxCommandReply reply = await session.DispatchAsync(CreateRemoveItemCommand( + "remove-item-failure", + 999, + 504)); + + Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code); + Assert.True(reply.HasHresult); + Assert.Equal(hresult, reply.Hresult); + Assert.Contains("0x80070057", reply.DiagnosticMessage); + Assert.Equal(999, fakeComObject.RemoveItemServerHandle); + Assert.Equal(504, fakeComObject.RemovedItemHandle); + + RegisteredItemHandle registeredItemHandle = Assert.Single( + await session.GetRegisteredItemHandlesAsync()); + Assert.Equal(49, registeredItemHandle.ServerHandle); + Assert.Equal(504, registeredItemHandle.ItemHandle); + } + + [Fact] + public async Task DispatchAsync_AddItem2WhenMxAccessThrows_PreservesHResultAndDoesNotTrackItemHandle() + { + const int hresult = unchecked((int)0x80070057); + FakeMxAccessComObject fakeComObject = new( + registerHandle: 50, + addItem2Exception: new COMException("Invalid server handle.", hresult)); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + await session.StartAsync(workerProcessId: 1234); + + MxCommandReply reply = await session.DispatchAsync(CreateAddItem2Command( + "add-item2-failure", + 9001, + "TestInt", + "TestChildObject")); + + Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code); + Assert.True(reply.HasHresult); + Assert.Equal(hresult, reply.Hresult); + Assert.Contains("0x80070057", reply.DiagnosticMessage); + Assert.Equal(9001, fakeComObject.AddItem2ServerHandle); + Assert.Equal("TestInt", fakeComObject.AddItem2Definition); + Assert.Equal("TestChildObject", fakeComObject.AddItem2Context); + Assert.Empty(await session.GetRegisteredItemHandlesAsync()); + } + [Fact] public async Task DispatchAsync_RegisterWithoutPayload_ReturnsInvalidRequest() { @@ -98,6 +258,26 @@ public sealed class MxAccessCommandExecutorTests Assert.Null(factory.FakeComObject.RegisteredClientName); } + [Fact] + public async Task DispatchAsync_AddItemWithoutPayload_ReturnsInvalidRequest() + { + FakeMxAccessComObjectFactory factory = new(new FakeMxAccessComObject(registerHandle: 51)); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + await session.StartAsync(workerProcessId: 1234); + + MxCommandReply reply = await session.DispatchAsync(new StaCommand( + "session-1", + "missing-add-payload", + new MxCommand + { + Kind = MxCommandKind.AddItem, + })); + + Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); + Assert.Null(factory.FakeComObject.AddItemDefinition); + } + private static StaCommand CreateRegisterCommand( string correlationId, string clientName) @@ -132,6 +312,65 @@ public sealed class MxAccessCommandExecutorTests }); } + private static StaCommand CreateAddItemCommand( + string correlationId, + int serverHandle, + string itemDefinition) + { + return new StaCommand( + "session-1", + correlationId, + new MxCommand + { + Kind = MxCommandKind.AddItem, + AddItem = new AddItemCommand + { + ServerHandle = serverHandle, + ItemDefinition = itemDefinition, + }, + }); + } + + private static StaCommand CreateAddItem2Command( + string correlationId, + int serverHandle, + string itemDefinition, + string itemContext) + { + return new StaCommand( + "session-1", + correlationId, + new MxCommand + { + Kind = MxCommandKind.AddItem2, + AddItem2 = new AddItem2Command + { + ServerHandle = serverHandle, + ItemDefinition = itemDefinition, + ItemContext = itemContext, + }, + }); + } + + private static StaCommand CreateRemoveItemCommand( + string correlationId, + int serverHandle, + int itemHandle) + { + return new StaCommand( + "session-1", + correlationId, + new MxCommand + { + Kind = MxCommandKind.RemoveItem, + RemoveItem = new RemoveItemCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + }, + }); + } + private static StaRuntime CreateRuntime() { return new StaRuntime( @@ -143,14 +382,29 @@ public sealed class MxAccessCommandExecutorTests private sealed class FakeMxAccessComObject { private readonly int registerHandle; + private readonly int addItemHandle; + private readonly int addItem2Handle; private readonly Exception? unregisterException; + private readonly Exception? addItemException; + private readonly Exception? addItem2Exception; + private readonly Exception? removeItemException; public FakeMxAccessComObject( int registerHandle, - Exception? unregisterException = null) + int addItemHandle = 0, + int addItem2Handle = 0, + Exception? unregisterException = null, + Exception? addItemException = null, + Exception? addItem2Exception = null, + Exception? removeItemException = null) { this.registerHandle = registerHandle; + this.addItemHandle = addItemHandle; + this.addItem2Handle = addItem2Handle; this.unregisterException = unregisterException; + this.addItemException = addItemException; + this.addItem2Exception = addItem2Exception; + this.removeItemException = removeItemException; } public string? RegisteredClientName { get; private set; } @@ -161,6 +415,26 @@ public sealed class MxAccessCommandExecutorTests public int? UnregisterThreadId { get; private set; } + public int? AddItemServerHandle { get; private set; } + + public string? AddItemDefinition { get; private set; } + + public int? AddItemThreadId { get; private set; } + + public int? AddItem2ServerHandle { get; private set; } + + public string? AddItem2Definition { get; private set; } + + public string? AddItem2Context { get; private set; } + + public int? AddItem2ThreadId { get; private set; } + + public int? RemoveItemServerHandle { get; private set; } + + public int? RemovedItemHandle { get; private set; } + + public int? RemoveItemThreadId { get; private set; } + public int Register(string clientName) { RegisteredClientName = clientName; @@ -179,6 +453,54 @@ public sealed class MxAccessCommandExecutorTests throw unregisterException; } } + + public int AddItem( + int serverHandle, + string itemDefinition) + { + AddItemServerHandle = serverHandle; + AddItemDefinition = itemDefinition; + AddItemThreadId = Environment.CurrentManagedThreadId; + + if (addItemException is not null) + { + throw addItemException; + } + + return addItemHandle; + } + + public int AddItem2( + int serverHandle, + string itemDefinition, + string itemContext) + { + AddItem2ServerHandle = serverHandle; + AddItem2Definition = itemDefinition; + AddItem2Context = itemContext; + AddItem2ThreadId = Environment.CurrentManagedThreadId; + + if (addItem2Exception is not null) + { + throw addItem2Exception; + } + + return addItem2Handle; + } + + public void RemoveItem( + int serverHandle, + int itemHandle) + { + RemoveItemServerHandle = serverHandle; + RemovedItemHandle = itemHandle; + RemoveItemThreadId = Environment.CurrentManagedThreadId; + + if (removeItemException is not null) + { + throw removeItemException; + } + } } private sealed class FakeMxAccessComObjectFactory : IMxAccessComObjectFactory diff --git a/src/MxGateway.Worker.Tests/MxAccess/MxAccessLiveComCreationTests.cs b/src/MxGateway.Worker.Tests/MxAccess/MxAccessLiveComCreationTests.cs index f8ab696..ef2dffa 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/MxAccessLiveComCreationTests.cs +++ b/src/MxGateway.Worker.Tests/MxAccess/MxAccessLiveComCreationTests.cs @@ -8,6 +8,11 @@ namespace MxGateway.Worker.Tests.MxAccess; public sealed class MxAccessLiveComCreationTests { + private const string LiveClientName = "MxGateway.Worker.Tests"; + private const string DefaultLiveAddItemReference = "TestChildObject.TestInt"; + private const string DefaultLiveAddItem2Definition = "TestInt"; + private const string DefaultLiveAddItem2Context = "TestChildObject"; + [Fact] public async Task StartAsync_WhenOptedIn_CreatesInstalledMxAccessComObjectOnSta() { @@ -43,7 +48,7 @@ public sealed class MxAccessLiveComCreationTests Kind = MxCommandKind.Register, Register = new RegisterCommand { - ClientName = "MxGateway.Worker.Tests", + ClientName = LiveClientName, }, })); @@ -65,6 +70,151 @@ public sealed class MxAccessLiveComCreationTests Assert.Equal(ProtocolStatusCode.Ok, unregisterReply.ProtocolStatus.Code); } + [Fact] + public async Task AddItemAndRemoveItem_WhenOptedIn_RoundTripsInstalledMxAccessItemHandle() + { + if (!RunLiveMxAccessTests()) + { + return; + } + + using MxAccessStaSession session = new(); + await session.StartAsync(workerProcessId: 1234); + + MxCommandReply registerReply = await RegisterLiveSessionAsync(session, "live-add-register"); + int serverHandle = registerReply.Register.ServerHandle; + int itemHandle = 0; + + try + { + MxCommandReply addItemReply = await session.DispatchAsync(new StaCommand( + "session-1", + "live-add-item", + new MxCommand + { + Kind = MxCommandKind.AddItem, + AddItem = new AddItemCommand + { + ServerHandle = serverHandle, + ItemDefinition = GetLiveAddItemReference(), + }, + })); + + Assert.Equal(ProtocolStatusCode.Ok, addItemReply.ProtocolStatus.Code); + Assert.True(addItemReply.AddItem.ItemHandle > 0); + itemHandle = addItemReply.AddItem.ItemHandle; + + MxCommandReply removeItemReply = await session.DispatchAsync(new StaCommand( + "session-1", + "live-remove-item", + new MxCommand + { + Kind = MxCommandKind.RemoveItem, + RemoveItem = new RemoveItemCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + }, + })); + + Assert.Equal(ProtocolStatusCode.Ok, removeItemReply.ProtocolStatus.Code); + itemHandle = 0; + } + finally + { + if (itemHandle > 0) + { + await session.DispatchAsync(new StaCommand( + "session-1", + "live-remove-item-cleanup", + new MxCommand + { + Kind = MxCommandKind.RemoveItem, + RemoveItem = new RemoveItemCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + }, + })); + } + + await UnregisterLiveSessionAsync(session, serverHandle, "live-add-unregister"); + } + } + + [Fact] + public async Task AddItem2AndRemoveItem_WhenOptedIn_PreservesContextForInstalledMxAccess() + { + if (!RunLiveMxAccessTests()) + { + return; + } + + using MxAccessStaSession session = new(); + await session.StartAsync(workerProcessId: 1234); + + MxCommandReply registerReply = await RegisterLiveSessionAsync(session, "live-add2-register"); + int serverHandle = registerReply.Register.ServerHandle; + int itemHandle = 0; + + try + { + MxCommandReply addItem2Reply = await session.DispatchAsync(new StaCommand( + "session-1", + "live-add-item2", + new MxCommand + { + Kind = MxCommandKind.AddItem2, + AddItem2 = new AddItem2Command + { + ServerHandle = serverHandle, + ItemDefinition = DefaultLiveAddItem2Definition, + ItemContext = DefaultLiveAddItem2Context, + }, + })); + + Assert.Equal(ProtocolStatusCode.Ok, addItem2Reply.ProtocolStatus.Code); + Assert.True(addItem2Reply.AddItem2.ItemHandle > 0); + itemHandle = addItem2Reply.AddItem2.ItemHandle; + + MxCommandReply removeItemReply = await session.DispatchAsync(new StaCommand( + "session-1", + "live-remove-item2", + new MxCommand + { + Kind = MxCommandKind.RemoveItem, + RemoveItem = new RemoveItemCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + }, + })); + + Assert.Equal(ProtocolStatusCode.Ok, removeItemReply.ProtocolStatus.Code); + itemHandle = 0; + } + finally + { + if (itemHandle > 0) + { + await session.DispatchAsync(new StaCommand( + "session-1", + "live-remove-item2-cleanup", + new MxCommand + { + Kind = MxCommandKind.RemoveItem, + RemoveItem = new RemoveItemCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + }, + })); + } + + await UnregisterLiveSessionAsync(session, serverHandle, "live-add2-unregister"); + } + } + private static bool RunLiveMxAccessTests() { return string.Equals( @@ -72,4 +222,55 @@ public sealed class MxAccessLiveComCreationTests "1", StringComparison.Ordinal); } + + private static string GetLiveAddItemReference() + { + string itemReference = Environment.GetEnvironmentVariable("MXGATEWAY_LIVE_MXACCESS_ITEM"); + + return string.IsNullOrWhiteSpace(itemReference) + ? DefaultLiveAddItemReference + : itemReference; + } + + private static async Task RegisterLiveSessionAsync( + MxAccessStaSession session, + string correlationId) + { + MxCommandReply reply = await session.DispatchAsync(new StaCommand( + "session-1", + correlationId, + new MxCommand + { + Kind = MxCommandKind.Register, + Register = new RegisterCommand + { + ClientName = LiveClientName, + }, + })); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.True(reply.Register.ServerHandle > 0); + + return reply; + } + + private static async Task UnregisterLiveSessionAsync( + MxAccessStaSession session, + int serverHandle, + string correlationId) + { + MxCommandReply unregisterReply = await session.DispatchAsync(new StaCommand( + "session-1", + correlationId, + new MxCommand + { + Kind = MxCommandKind.Unregister, + Unregister = new UnregisterCommand + { + ServerHandle = serverHandle, + }, + })); + + Assert.Equal(ProtocolStatusCode.Ok, unregisterReply.ProtocolStatus.Code); + } } diff --git a/src/MxGateway.Worker/MxAccess/IMxAccessServer.cs b/src/MxGateway.Worker/MxAccess/IMxAccessServer.cs index 5ad7525..b5f999d 100644 --- a/src/MxGateway.Worker/MxAccess/IMxAccessServer.cs +++ b/src/MxGateway.Worker/MxAccess/IMxAccessServer.cs @@ -5,4 +5,17 @@ public interface IMxAccessServer int Register(string clientName); void Unregister(int serverHandle); + + int AddItem( + int serverHandle, + string itemDefinition); + + int AddItem2( + int serverHandle, + string itemDefinition, + string itemContext); + + void RemoveItem( + int serverHandle, + int itemHandle); } diff --git a/src/MxGateway.Worker/MxAccess/MxAccessComServer.cs b/src/MxGateway.Worker/MxAccess/MxAccessComServer.cs index e1a601b..7c687ed 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessComServer.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessComServer.cs @@ -35,6 +35,44 @@ public sealed class MxAccessComServer : IMxAccessServer Invoke(nameof(Unregister), serverHandle); } + public int AddItem( + int serverHandle, + string itemDefinition) + { + if (mxAccessComObject is ILMXProxyServer mxAccessServer) + { + return mxAccessServer.AddItem(serverHandle, itemDefinition); + } + + return (int)Invoke(nameof(AddItem), serverHandle, itemDefinition); + } + + public int AddItem2( + int serverHandle, + string itemDefinition, + string itemContext) + { + if (mxAccessComObject is ILMXProxyServer3 mxAccessServer) + { + return mxAccessServer.AddItem2(serverHandle, itemDefinition, itemContext); + } + + return (int)Invoke(nameof(AddItem2), serverHandle, itemDefinition, itemContext); + } + + public void RemoveItem( + int serverHandle, + int itemHandle) + { + if (mxAccessComObject is ILMXProxyServer mxAccessServer) + { + mxAccessServer.RemoveItem(serverHandle, itemHandle); + return; + } + + Invoke(nameof(RemoveItem), serverHandle, itemHandle); + } + private object Invoke( string methodName, params object[] arguments) diff --git a/src/MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs b/src/MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs index 9c6ebb1..09d62e1 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs @@ -34,6 +34,9 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor { MxCommandKind.Register => ExecuteRegister(command), MxCommandKind.Unregister => ExecuteUnregister(command), + MxCommandKind.AddItem => ExecuteAddItem(command), + MxCommandKind.AddItem2 => ExecuteAddItem2(command), + MxCommandKind.RemoveItem => ExecuteRemoveItem(command), _ => CreateInvalidRequestReply(command, $"Unsupported MXAccess command kind {command.Kind}."), }; } @@ -67,6 +70,66 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor return CreateOkReply(command); } + private MxCommandReply ExecuteAddItem(StaCommand command) + { + if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.AddItem) + { + return CreateInvalidRequestReply(command, "AddItem command payload is required."); + } + + AddItemCommand addItemCommand = command.Command.AddItem; + int itemHandle = session.AddItem( + addItemCommand.ServerHandle, + addItemCommand.ItemDefinition); + + MxCommandReply reply = CreateOkReply(command); + reply.ReturnValue = variantConverter.Convert(itemHandle); + reply.AddItem = new AddItemReply + { + ItemHandle = itemHandle, + }; + + return reply; + } + + private MxCommandReply ExecuteAddItem2(StaCommand command) + { + if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.AddItem2) + { + return CreateInvalidRequestReply(command, "AddItem2 command payload is required."); + } + + AddItem2Command addItem2Command = command.Command.AddItem2; + int itemHandle = session.AddItem2( + addItem2Command.ServerHandle, + addItem2Command.ItemDefinition, + addItem2Command.ItemContext); + + MxCommandReply reply = CreateOkReply(command); + reply.ReturnValue = variantConverter.Convert(itemHandle); + reply.AddItem2 = new AddItem2Reply + { + ItemHandle = itemHandle, + }; + + return reply; + } + + private MxCommandReply ExecuteRemoveItem(StaCommand command) + { + if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.RemoveItem) + { + return CreateInvalidRequestReply(command, "RemoveItem command payload is required."); + } + + RemoveItemCommand removeItemCommand = command.Command.RemoveItem; + session.RemoveItem( + removeItemCommand.ServerHandle, + removeItemCommand.ItemHandle); + + return CreateOkReply(command); + } + private static MxCommandReply CreateOkReply(StaCommand command) { return new MxCommandReply diff --git a/src/MxGateway.Worker/MxAccess/MxAccessHandleRegistry.cs b/src/MxGateway.Worker/MxAccess/MxAccessHandleRegistry.cs index 669acb3..fb398d3 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessHandleRegistry.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessHandleRegistry.cs @@ -6,12 +6,19 @@ namespace MxGateway.Worker.MxAccess; public sealed class MxAccessHandleRegistry { private readonly Dictionary serverHandles = new(); + private readonly Dictionary itemHandles = new(); public IReadOnlyList ServerHandles => serverHandles .Values .OrderBy(handle => handle.ServerHandle) .ToArray(); + public IReadOnlyList ItemHandles => itemHandles + .Values + .OrderBy(handle => handle.ServerHandle) + .ThenBy(handle => handle.ItemHandle) + .ToArray(); + public void RegisterServerHandle( int serverHandle, string clientName) @@ -22,10 +29,54 @@ public sealed class MxAccessHandleRegistry public void UnregisterServerHandle(int serverHandle) { serverHandles.Remove(serverHandle); + + foreach (long key in itemHandles + .Where(pair => pair.Value.ServerHandle == serverHandle) + .Select(pair => pair.Key) + .ToArray()) + { + itemHandles.Remove(key); + } } public bool ContainsServerHandle(int serverHandle) { return serverHandles.ContainsKey(serverHandle); } + + public void RegisterItemHandle( + int serverHandle, + int itemHandle, + string itemDefinition, + string itemContext, + bool hasItemContext) + { + itemHandles[CreateItemKey(serverHandle, itemHandle)] = new RegisteredItemHandle( + serverHandle, + itemHandle, + itemDefinition, + itemContext, + hasItemContext); + } + + public void RemoveItemHandle( + int serverHandle, + int itemHandle) + { + itemHandles.Remove(CreateItemKey(serverHandle, itemHandle)); + } + + public bool ContainsItemHandle( + int serverHandle, + int itemHandle) + { + return itemHandles.ContainsKey(CreateItemKey(serverHandle, itemHandle)); + } + + private static long CreateItemKey( + int serverHandle, + int itemHandle) + { + return ((long)serverHandle << 32) | (uint)itemHandle; + } } diff --git a/src/MxGateway.Worker/MxAccess/MxAccessSession.cs b/src/MxGateway.Worker/MxAccess/MxAccessSession.cs index 6874ef0..b8cd960 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessSession.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessSession.cs @@ -106,6 +106,51 @@ public sealed class MxAccessSession : IDisposable handleRegistry.UnregisterServerHandle(serverHandle); } + public int AddItem( + int serverHandle, + string itemDefinition) + { + ThrowIfDisposed(); + + int itemHandle = mxAccessServer.AddItem(serverHandle, itemDefinition); + handleRegistry.RegisterItemHandle( + serverHandle, + itemHandle, + itemDefinition, + string.Empty, + hasItemContext: false); + + return itemHandle; + } + + public int AddItem2( + int serverHandle, + string itemDefinition, + string itemContext) + { + ThrowIfDisposed(); + + int itemHandle = mxAccessServer.AddItem2(serverHandle, itemDefinition, itemContext); + handleRegistry.RegisterItemHandle( + serverHandle, + itemHandle, + itemDefinition, + itemContext, + hasItemContext: true); + + return itemHandle; + } + + public void RemoveItem( + int serverHandle, + int itemHandle) + { + ThrowIfDisposed(); + + mxAccessServer.RemoveItem(serverHandle, itemHandle); + handleRegistry.RemoveItemHandle(serverHandle, itemHandle); + } + public void Dispose() { if (disposed) diff --git a/src/MxGateway.Worker/MxAccess/MxAccessStaSession.cs b/src/MxGateway.Worker/MxAccess/MxAccessStaSession.cs index 945633b..de522f3 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessStaSession.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessStaSession.cs @@ -81,6 +81,19 @@ public sealed class MxAccessStaSession : IDisposable cancellationToken); } + public Task> GetRegisteredItemHandlesAsync( + CancellationToken cancellationToken = default) + { + if (session is null) + { + throw new InvalidOperationException("MXAccess COM session has not been started."); + } + + return staRuntime.InvokeAsync( + () => session.HandleRegistry.ItemHandles, + cancellationToken); + } + public void Dispose() { if (disposed) diff --git a/src/MxGateway.Worker/MxAccess/RegisteredItemHandle.cs b/src/MxGateway.Worker/MxAccess/RegisteredItemHandle.cs new file mode 100644 index 0000000..15267d9 --- /dev/null +++ b/src/MxGateway.Worker/MxAccess/RegisteredItemHandle.cs @@ -0,0 +1,28 @@ +namespace MxGateway.Worker.MxAccess; + +public sealed class RegisteredItemHandle +{ + public RegisteredItemHandle( + int serverHandle, + int itemHandle, + string itemDefinition, + string itemContext, + bool hasItemContext) + { + ServerHandle = serverHandle; + ItemHandle = itemHandle; + ItemDefinition = itemDefinition; + ItemContext = itemContext; + HasItemContext = hasItemContext; + } + + public int ServerHandle { get; } + + public int ItemHandle { get; } + + public string ItemDefinition { get; } + + public string ItemContext { get; } + + public bool HasItemContext { get; } +}